The changes feed, GET /v1/sync/changes, is how you keep your own copy of a team. Your first request copies every record. After that, each request returns only what changed since the last one, so a sync every few minutes is cheap and never misses a change.
- Start a team without a cursor: your first request to GET /v1/sync/changes begins its initial sync.
- Call GET /v1/sync/changes with the team in Totem-Team and your saved cursor.
- Apply the page: skip any item whose version you already hold, store each record, and delete each removed record while keeping its version as a tombstone.
- Save next_cursor, only after the whole page is stored.
- If has_more is true, ask again at once. If it is false, you are up to date: wait 1 to 5 minutes, then ask again with the saved cursor.
- If the request fails:
- 410 sync_cursor_expired or 409 resync_required: Full resync: forget the versions, then mark and sweep.
- 409 capture_not_ready: Totem is still preparing the team. Retry in a few minutes.
- 403: Keep the data and the cursor. Try again later.
- 404 team_not_accessible: Confirm with GET /v1/teams, then drop the team.
- 429: Wait for Retry-After, then retry the same cursor.
- 5xx: Retry with exponential backoff.
How the feed works
There is one feed per team. A key that reads several teams names the team in the Totem-Team header and keeps one cursor per team.
Each item of the feed is the current state of one record, of one kind:
lead,lead_attributionandlead_response: a lead and its two facets;activity: a change of a lead's stage, status or assigned members;call,conversationandbooking;board, with its stages inside it.
A lead's attribution and response travel as kinds of their own, with the lead's id and a version of their own. That is why a new message changes the lead's response without changing the lead. Members and bookable services are not in the feed: you read them separately.
Items come in the order Totem recorded the changes, oldest first. An item is never a diff: it carries the whole record as it is now.
Records and removals
Most items carry the record under object. It is the same object the list endpoints return, shortened here:
{
"kind": "call",
"id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk",
"version": 2,
"object": {
"id": "call_m2kUbFcayHlZ5Lgs9xZNmDS3c3Jvk",
"object": "call",
"livemode": false,
"team": "team_r9uYrIW4gSFJJ6hZQ9ip1UIpPaAD1CF7NR",
"direction": "inbound",
"outcome": "answered_ai",
"version": 2
}
}A removal carries removed instead of object. It tells you to delete the record:
{
"kind": "call",
"id": "call_m1pf4APjJWVi3Z7ulyNaW8y3lUi7V",
"version": 5,
"removed": { "reason": "deleted" }
}reason is erased when the record was erased: for privacy, for example at the person's request, or because someone permanently deleted a lead, a call or a conversation in Totem, which always erases it. It is deleted when the record went away in any other way, for example when it no longer belongs to the team's API data, or when Totem now names it with a new id. Handle both the same way, as Apply a page explains. A record that comes back is a new record with a new id (Conventions), so match people across ids by phone or email with search, never by id.
Plan your storage
A copy built this way can apply every page safely, whatever order the records arrive in:
- One row per record, keyed by team,
kindandid, with itsversion. A lead and its two facets share an id, so thekindis part of the key. - One tombstone per removal: the
kind,idandversionof a record you deleted, without its data. - One cursor per team, saved together with the page it follows when your database allows it.
- Boards, members and bookable services in lists of their own, replaced as a whole each time you read them.
- No foreign keys between records while you apply pages. A record can name another that has not reached you yet, such as a lead naming a stage of a board, or an activity naming its lead.
Then run one sync per key every 1 to 5 minutes, and watch for a team that keeps being skipped or postponed, or whose cursor has not been saved for a day.
Run the initial sync
Your first request for a team has no cursor. It starts the initial sync: the feed pages through every record of the team, oldest change first.
curl "https://api.totemstudio.ai/v1/sync/changes?limit=100" \
-H "Authorization: Bearer $TOTEM_API_KEY" \
-H "Totem-Team: $TOTEM_TEAM_ID"Apply the page, store its next_cursor, and ask again with cursor set to it. Repeat while has_more is true. When has_more is false, your copy is complete. Ask for the largest page, 100 items, so the initial sync takes as few requests as possible.
Two things are normal during an initial sync. A record that changes while it runs comes again later, with a higher version. And after a retry, you can receive the same page twice. The version check in the next section makes both harmless.
Apply a page
For each item of the page, in order:
- Skip what you already have. If you hold this
kindandidwith the same or a higherversion, skip the item. Compare with the versions of your tombstones too. - Store a record. If the item has
object, insert your copy or replace it. For a lead, store itscontact_permissionsbefore anything else can act on it. - Apply a removal. If the item has
removed, delete your copy, but keep itskind,idandversionas a tombstone. When the removed record is a lead, also delete its attribution, its response and its activities, and ignore any of them that arrives later.
Save the cursor
Store next_cursor only after the whole page is saved. If your process stops in between, the next run reads the same page again, and step 1 skips what you already applied. When your database allows it, save the page and its cursor in one transaction.
A cursor is opaque: store it exactly as you received it, and never build or edit one. It belongs to one team. Sent with another team, it gets 400 invalid_request.
A cursor expires 30 days after the point in the feed it reached. While an initial sync is still running, that age counts from when the initial sync started. A team you poll regularly never reaches it.
Poll for changes
Once has_more is false, ask again with your stored cursor every 1 to 5 minutes. When nothing has changed, you get an empty page with a new next_cursor. Store it too: an empty page moves the cursor forward, so the cursor of a quiet team never grows old.
The feed holds back the last 10 seconds of changes, so that a change still being written is never skipped. A change shows up in the feed shortly after it happens in Totem.
Run at most one sync process per key and team. Two processes applying pages of the same team can save their cursors out of order.
Removals, merges and transfers
Removals
Apply a removal as soon as you receive it: delete the record from your copy and from every system you copied it to. Removals stay in the feed for 45 days, longer than a cursor lives, so a sync that keeps reading the feed successfully never misses one. While the API refuses a team, it delivers nothing for that team, removals included; Recover from errors says what to do then.
When a lead is removed, its attribution, response and activities are removed with it, and the feed delivers their removals too. What happens to the lead's other records depends on the reason:
erased, which includes a lead deleted permanently in Totem: its calls and conversations are erased too, and so are the archived leads whosemerged_intonames it, with their own calls and conversations. Each record arrives as a removal of its own, withreason: "erased". Its bookings are not removed: they arrive again withleadset tonull.deleted: its calls, conversations and bookings are not removed. They arrive again withleadset tonull.
Merges
When two leads of the same person are merged in Totem, the lead that is not kept is archived, not removed. Its archive_reason is merged, and its merged_into names the lead that was kept. Link your copy to that lead instead of creating the person twice. The lead that is kept also takes the stricter contact permissions of the two. merged_into is null when the lead it named no longer exists.
Transfers
When a lead moves to another team of the same organization, the original lead is archived with archive_reason set to family_transfer, and transferred_to says where it went:
transferred_to.teamis always set. It can be a team your key does not read.transferred_to.leadis the lead in that team, ornullwhen Totem cannot name it.
If your key reads the destination team, the new lead arrives in that team's feed. If it does not, GET /v1/leads/{id} on the destination lead answers 404 resource_missing. Keep the archived lead as the record of where the person went, and do not guess.
Contact permissions first
Before any of your systems contacts a lead, store its contact_permissions and apply them:
do_not_contact: true: the person asked never to be contacted. Stop every channel.email_opt_out: true: the person unsubscribed from marketing and follow-up email.whatsapp_consent: false: the person refused WhatsApp.nullmeans nobody recorded an answer, so never treat it as consent.
A lead in the feed is data, not permission to contact the person. The legal basis for contacting someone stays your responsibility.
The API is read-only, so Totem cannot learn about an opt-out recorded in your systems. When a person opts out there, make sure it is also recorded on the lead in Totem, so that Totem stops contacting them too.
Recover from errors
When a request for a page fails, do not save a cursor. The next request with the same cursor asks for the same page again.
| Answer | What it means | What to do |
|---|---|---|
410 sync_cursor_expired | The cursor is more than 30 days old. | Run a full resync. |
409 resync_required | Totem cannot continue from this cursor, for example after restoring a backup. | Run a full resync. |
409 capture_not_ready | Totem is still preparing this team's data. It happens once, when a team is first added to a key. | Keep everything and try again in a few minutes. Never resync for it. The key's other teams keep syncing. |
403 | Access is refused for now: for example, the team is restricted (team_state_restricted) or the endpoint is switched off (capability_disabled). | Keep the team's data and cursor, and skip the team in this run. The Errors page says, code by code, whether waiting helps. plan_not_entitled refuses the whole key, so the run stops at GET /v1/teams. While access is refused, the feed delivers nothing for the team, removals included: if the refusal lasts longer than your retention policy allows, delete the team's copy. |
404 team_not_accessible | The key may no longer read this team. | Read GET /v1/teams. If the team is not listed, delete its data and its cursor. If it is, keep everything and try again on the next run. |
400 invalid_request | The cursor was changed, or it belongs to another team. | Use the cursor you stored for this team. |
429 | You reached the rate limit or the daily quota. | Wait the seconds in Retry-After, then retry with the same cursor. A 429 from the firewall has no Retry-After: back off exponentially. |
5xx | Something failed inside Totem. | Retry with exponential backoff. |
Every code is explained on the Errors page.
Full resync
A full resync rebuilds your copy of one team, by mark and sweep:
- Forget the versions and the cursor you stored for the team. Keep the records themselves: your copy stays usable while the resync runs.
- Run the initial sync again, without a cursor. Apply each page as usual, and mark every record the feed delivers.
- When
has_moreisfalse, delete every record of the team that you did not mark.
Store the marks durably. If the process stops halfway, the next run must continue the same resync, not sweep with half the marks. Forgetting the versions first matters too: after a restore, the feed can deliver lower versions than the ones you held.
When teams change
Read GET /v1/teams at the start of every run. It lists every team your key reads, including teams that are restricted for now.
- A team you have not synced yet: run its initial sync.
- A team you hold that is no longer listed: it has left the key. Delete its data and its cursor.
- A listed team that answers
403: keep it, as above.
Refresh boards, members and services
Members and bookable services are not in the feed. Boards are, but a lead can name a new stage before the change to its board reaches you. So read all three:
- when you start syncing a team;
- when an item names a board, stage, member or service you do not know;
- at least once a day.
A record can keep naming a member who left the team, or a board, stage or service that was deleted. If a refresh does not find an id, remember it and do not refresh for it again before the daily refresh.
GET /v1/boards pages like any list. GET /v1/members and GET /v1/booking_services return the whole list in one response and take no limit or cursor. Replace your copy of each list as a whole. A member who leaves the team, or a service that is deleted, drops out of its list while older records keep naming its id: if you show names for older records, keep the name from an earlier read. Disabled members and services stay listed, with nothing that marks them as disabled.
Pace
A key reads up to 2,000 items a minute, so the initial sync of a large team takes a while. Rate limits has the numbers, an estimate of how long an initial sync takes, and why the numbers are approximate during the Beta. When you get a 429, wait for Retry-After and continue with the same cursor: nothing is lost. The limits belong to the key, so a 429 pauses every team the key reads, not just the one you were syncing.
Tested reference client
The TypeScript below is a complete sync client that does everything this page describes. Totem runs it in its own tests against a simulated feed, with crashes, replayed pages, removals, resyncs and errors; the code shown here is that exact file. It has no dependencies and needs only fetch: compile it with your TypeScript build, or run it directly on Node.js 22.18 or later. Copy it to your server and implement SyncStore on your database.
Run it from a scheduled job every 1 to 5 minutes, one run per key at a time. The example is an ES module; under Node.js module resolution, import the file with the .js extension your build emits, or with .ts when Node.js runs the TypeScript directly:
import { syncKey } from './syncTeam.js'
const outcomes = await syncKey({ key: process.env.TOTEM_API_KEY!, store })The key must never reach a browser. Every SyncStore method must write durably before its promise resolves, and writes for one team must not run in parallel.
Options and outcomes
syncKey reads GET /v1/teams, deletes the teams that left the key, and syncs every listed team in turn. It returns one outcome per team:
synced: the team is caught up and its cursor is saved.partial: the team readmaxPagesPerTeampages and is not caught up yet. Its cursor is saved, and the next run continues from it.dropped: the team left the key, and its copy and cursor are deleted.skipped, with thecode: the team cannot be read right now (403,capture_not_ready, or ateam_not_accessiblethatGET /v1/teamsdoes not confirm). Its copy and cursor are kept, and the next run tries again.retry_later, with thecodeandretryAt(epoch milliseconds): the API asked for a longer wait than this run allows, or a429or5xxwas still failing aftermaxRetriesretries. The copy and cursor are kept. Do not sync that team again beforeretryAt. A429belongs to the key, not to one team, so once one sends the key away, every team this run has not reached yet is reportedretry_laterwith the samecodeandretryAt, without a request.
A team that is restricted, still being prepared or gone never stops the others; a 429 this run cannot wait out pauses the whole key until retryAt. syncKey throws when the key itself is refused, when GET /v1/teams cannot be read, or on an error that points to a bug rather than to the team: a 400, an unexpected 404, a feed that still asks for a full resync right after one, a list or feed that repeats a cursor or leaves one out, or a network failure. No cursor is lost then either; when the error carries retryAt, schedule the next run for that time.
Besides key and store, it takes:
maxWaitMs: the longest it sleeps in one go, in milliseconds. The default is 60,000. A longerRetry-After, such as the wait for the daily quota to reset at 00:00 UTC, ends the run withretry_laterinstead of keeping your process asleep.maxRetries: how many times one request is retried after a429or a5xx. The default is 3.maxPagesPerTeam: the most feed pages one team reads in one run. There is no limit by default. With a limit, a long initial sync stops withpartial, the key's other teams are synced, and the next run continues from the saved cursor.fetch,baseUrl,sleepandnow: replace them in your tests.
/**
* Reference receiver for the Totem changes feed. No dependencies: it needs
* only `fetch`. Compile it with your TypeScript build, or run it directly on
* Node.js 22.18 or later.
*
* It keeps a copy of every team a key reads, safely across crashes:
* - Run it on your server only. The key is a secret and must never reach a browser.
* - Run one `syncKey` per key at a time, every 1 to 5 minutes.
* - Persist the `SyncStore` in your own database. `MemorySyncStore`
* (memoryStore.ts) loses everything on restart and is for tests only.
* - Each page is applied before its cursor is saved, so a crash in between
* replays the page, and the version check makes the replay harmless.
* - One team's trouble never stops the others: a team that cannot be read
* right now keeps its copy and its cursor, and the next run tries again.
* - It stops instead of looping if the API ever repeats a cursor or sends
* none, before saving it, so a fault on either side cannot spend the key's
* daily quota.
*/
export interface FeedItem {
kind: string
id: string
version: number
object?: Record<string, unknown>
removed?: { reason: string }
}
export interface FeedPage {
object: 'list'
data: FeedItem[]
has_more: boolean
next_cursor: string
}
interface ListPage {
data: unknown[]
has_more: boolean
next_cursor: string | null
}
/** Your persistence. Every method must be durable before its promise resolves. */
export interface SyncStore {
teamIds(): Promise<string[]>
ensureTeam(teamId: string): Promise<void>
cursor(teamId: string): Promise<string | null>
/** The version you hold for this record, removals included; 0 when you hold none. */
version(teamId: string, kind: string, id: string): Promise<number>
setContactPermissions(teamId: string, leadId: string, permissions: Record<string, unknown>): Promise<void>
upsert(teamId: string, item: FeedItem): Promise<void>
/**
* Delete the record but keep its version (a tombstone). Removing a lead also
* deletes its attribution, response and activities.
*/
remove(teamId: string, item: FeedItem): Promise<void>
saveCursor(teamId: string, cursor: string): Promise<void>
/** Start a full resync: forget the team's versions and cursor, keep its records, clear the seen set. */
startResync(teamId: string): Promise<void>
/** Remember that the feed still delivers this record. Must survive a restart until the resync finishes. */
markSeen(teamId: string, item: FeedItem): Promise<void>
/** End a full resync: delete the team's records the feed did not deliver. A no-op when no resync is running. */
finishResync(teamId: string): Promise<void>
/** Delete everything you hold for the team, cursor included. */
dropTeam(teamId: string): Promise<void>
dictionariesAt(teamId: string): Promise<number | null>
saveDictionaries(teamId: string, dictionaries: Record<string, unknown[]>, at: number): Promise<void>
/**
* True when the item names a board, stage, member or service that is neither
* in your dictionaries nor remembered as unresolved (`rememberUnresolved`).
*/
hasUnknownReferences(teamId: string, item: FeedItem): Promise<boolean>
/**
* Called for an item that still names unknown ids right after the
* dictionaries were refreshed. A record can keep naming a member who left
* the team, or a board, stage or service that was deleted: remember those
* ids so they never cause another refresh. The daily refresh still picks
* up any that come back.
*/
rememberUnresolved(teamId: string, item: FeedItem): Promise<void>
}
export interface SyncOptions {
/** A secret key (`tsk_live_…`). */
key: string
store: SyncStore
fetch?: typeof globalThis.fetch
baseUrl?: string
sleep?: (ms: number) => Promise<void>
now?: () => number
/** How many times one request is retried after a `429` or a `5xx`. Default 3. */
maxRetries?: number
/**
* The longest this process waits in one go, in milliseconds. Default 60 000.
* A longer `Retry-After` (the daily quota resets at 00:00 UTC) ends that
* team's run with `retryAt` instead of keeping the process asleep.
*/
maxWaitMs?: number
/**
* The most feed pages one team reads in one run. No limit by default. A
* team's initial sync can take hours: with a limit, it stops with `partial`
* after that many pages, the key's other teams are synced, and the next run
* continues from the saved cursor.
*/
maxPagesPerTeam?: number
}
/** What happened to one team in one `syncKey` run. */
export type TeamOutcome =
/** Caught up; the cursor is saved. */
| { team: string, status: 'synced' }
/** Read `maxPagesPerTeam` pages and not caught up yet; the cursor is saved and the next run continues. */
| { team: string, status: 'partial' }
/** The team left the key: its copy and cursor are deleted. */
| { team: string, status: 'dropped' }
/** Not readable right now (`403`, `capture_not_ready`, unconfirmed `team_not_accessible`); copy and cursor kept. */
| { team: string, status: 'skipped', code: string }
/**
* Asked to wait longer than `maxWaitMs`, or still failing after `maxRetries`;
* copy and cursor kept. Do not call before `retryAt` (epoch milliseconds).
*/
| { team: string, status: 'retry_later', code: string, retryAt: number }
/** An error answer from the API. `retryAt` (epoch milliseconds) is set when asking again later can succeed. */
export class SyncHttpError extends Error {
readonly status: number
readonly code: string
readonly retryAt: number | null
constructor(status: number, code: string, retryAt: number | null = null) {
super(`Totem API answered ${status} ${code}`)
this.name = 'SyncHttpError'
this.status = status
this.code = code
this.retryAt = retryAt
}
}
const DEFAULT_BASE_URL = 'https://api.totemstudio.ai/v1'
const DAY_MS = 86_400_000
/**
* The API always moves a cursor forward. Throws when a page says there is
* more but repeats a cursor this loop already used, or sends none: following
* it would read the same page forever.
*/
function nextCursor(seen: Set<string>, cursor: string | null): string {
if (!cursor || seen.has(cursor)) throw new Error('Totem API sent a missing or repeated cursor; stopping so the loop cannot spend the key\'s quota')
seen.add(cursor)
return cursor
}
/** `Retry-After` is always whole seconds. Null when the header is missing or unreadable. */
function retryAfterMs(response: Response): number | null {
const header = response.headers.get('Retry-After')
if (header === null || header.trim() === '') return null
const seconds = Number(header)
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null
}
/** One GET. A `429` or `5xx` is retried while the wait fits within `maxWaitMs`. */
async function get<T>(options: SyncOptions, path: string, team?: string): Promise<T> {
const fetcher = options.fetch ?? globalThis.fetch
const sleep = options.sleep ?? (ms => new Promise<void>(resolve => setTimeout(resolve, ms)))
const now = options.now ?? Date.now
const maxRetries = options.maxRetries ?? 3
const maxWaitMs = options.maxWaitMs ?? 60_000
for (let attempt = 0; ; attempt++) {
const response = await fetcher(`${options.baseUrl ?? DEFAULT_BASE_URL}${path}`, {
headers: { Authorization: `Bearer ${options.key}`, ...(team ? { 'Totem-Team': team } : {}) },
})
if (response.ok) return await response.json() as T
// Errors are application/problem+json. The firewall in front of the API may answer without a body.
const problem = await response.json().catch(() => ({})) as { code?: string }
const code = problem.code ?? `http_${response.status}`
// Only a 429 or a 5xx is worth sending again within this run; the caller decides about the rest.
if (response.status !== 429 && response.status < 500) throw new SyncHttpError(response.status, code)
const waitMs = retryAfterMs(response) ?? 1000 * 2 ** attempt
// Too long to wait here (the daily quota, say), or out of retries: report when to come back.
if (waitMs > maxWaitMs || attempt >= maxRetries) throw new SyncHttpError(response.status, code, now() + waitMs)
await sleep(waitMs)
}
}
/** Every team `GET /v1/teams` lists. It lists restricted teams too, so a missing team has left the key. */
async function listedTeams(options: SyncOptions): Promise<string[]> {
const page = await get<{ data: Array<{ id: string }> }>(options, '/teams')
return page.data.map(team => team.id)
}
/**
* Members and bookable services are not in the feed: read them in full when a
* team starts, when an item names an id you do not know, and once a day.
* Boards do travel in the feed; they are read here as well because a lead can
* name a new stage before that board's change reaches you.
*/
async function refreshDictionaries(options: SyncOptions, teamId: string): Promise<void> {
const boards: unknown[] = []
const seen = new Set<string>()
let cursor: string | null = null
do {
const query: string = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
const page: ListPage = await get<ListPage>(options, `/boards?limit=100${query}`, teamId)
boards.push(...page.data)
cursor = page.has_more ? nextCursor(seen, page.next_cursor) : null
} while (cursor)
// These two arrive whole, in one response, and take no `limit` or `cursor`.
const members = await get<ListPage>(options, '/members', teamId)
const services = await get<ListPage>(options, '/booking_services', teamId)
const now = options.now ?? Date.now
const dictionaries = { boards, members: members.data, booking_services: services.data }
await options.store.saveDictionaries(teamId, dictionaries, now())
}
/** Applies one page. Every item is checked against the version you hold, removals included. */
async function applyPage(options: SyncOptions, teamId: string, page: FeedPage): Promise<void> {
const { store } = options
let refreshed = false
for (const item of page.data) {
// Apply only what is newer than your copy. A removal keeps its version as a
// tombstone, so an older copy replayed later cannot bring the record back.
if (item.version > await store.version(teamId, item.kind, item.id)) {
if (await store.hasUnknownReferences(teamId, item)) {
// One refresh per page at most. What it does not resolve (a member who
// left, a deleted stage) is remembered, so it never asks again.
if (!refreshed) {
await refreshDictionaries(options, teamId)
refreshed = true
}
await store.rememberUnresolved(teamId, item)
}
if (item.removed) {
await store.remove(teamId, item)
}
else {
// Commit contact permissions before anything downstream can act on the lead.
const permissions = item.kind === 'lead' ? item.object?.contact_permissions : undefined
if (permissions && typeof permissions === 'object') {
await store.setContactPermissions(teamId, item.id, permissions as Record<string, unknown>)
}
await store.upsert(teamId, item)
}
}
// During a full resync, remember every record the feed still delivers.
await store.markSeen(teamId, item)
}
}
/**
* Brings one team's copy up to date: reads the feed from the saved cursor
* until it has caught up (`synced`), or until it has read `maxPagesPerTeam`
* pages (`partial`). Throws `SyncHttpError` when the API refuses; the saved
* cursor is untouched, so the next run resumes from the same place.
*/
export async function syncTeam(options: SyncOptions & { teamId: string }): Promise<'synced' | 'partial'> {
const { store, teamId } = options
const now = options.now ?? Date.now
await store.ensureTeam(teamId)
const dictionariesAt = await store.dictionariesAt(teamId)
if (dictionariesAt === null || now() - dictionariesAt >= DAY_MS) await refreshDictionaries(options, teamId)
let resyncing = false
let pages = 0
let seen = new Set<string>()
for (;;) {
const cursor = await store.cursor(teamId)
const query = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
let page: FeedPage
try {
page = await get<FeedPage>(options, `/sync/changes?limit=100${query}`, teamId)
}
catch (error) {
// The cursor expired (410) or the feed cannot continue from it (409 resync_required):
// run a full resync, once per run.
const restart = error instanceof SyncHttpError && (error.status === 410 || error.code === 'resync_required')
if (restart && !resyncing) {
await store.startResync(teamId)
resyncing = true
seen = new Set()
continue
}
throw error
}
await applyPage(options, teamId, page)
// Check the cursor before saving it, so a missing or repeated one never
// replaces the last good cursor. A last page only has to send one.
if (cursor) seen.add(cursor)
if (page.has_more || !page.next_cursor) nextCursor(seen, page.next_cursor)
// Save the cursor only after the page is applied, and save it for an empty
// page too: that keeps the cursor of a quiet team from expiring.
await store.saveCursor(teamId, page.next_cursor)
if (!page.has_more) {
// After a full resync, delete what the feed no longer delivers.
await store.finishResync(teamId)
return 'synced'
}
pages++
// Leave the rest for the next run, so the key's other teams are not kept waiting.
if (options.maxPagesPerTeam !== undefined && pages >= options.maxPagesPerTeam) return 'partial'
}
}
/**
* What one team's error means for this run. Every outcome but `dropped` keeps
* the team's copy and cursor. Any other error (a `401`, a `400`, an unexpected
* `404`, a feed that still asks for a resync after one) concerns the whole key
* or a bug, so it is rethrown and stops the run.
*/
async function outcomeOf(options: SyncOptions, team: string, error: SyncHttpError): Promise<TeamOutcome> {
// A 429 or 5xx that asked for a longer wait than this run allows (or kept failing).
if (error.retryAt !== null) return { team, status: 'retry_later', code: error.code, retryAt: error.retryAt }
// Restricted, sector-restricted or switched off: keep everything and skip the team.
if (error.status === 403) return { team, status: 'skipped', code: error.code }
// Totem is still preparing this team's data (once per team, ever): a later run reads it.
if (error.code === 'capture_not_ready') return { team, status: 'skipped', code: error.code }
// The team may have left the key. Only `/teams` can confirm it; a team it still lists keeps its copy.
if (error.code === 'team_not_accessible') {
if ((await listedTeams(options)).includes(team)) return { team, status: 'skipped', code: error.code }
await options.store.dropTeam(team)
return { team, status: 'dropped' }
}
throw error
}
/**
* Syncs every team the key reads and reports what happened to each. Re-reads
* `/teams` on every run, so a new team starts its initial sync and a team that
* left the key is deleted. Once a `429` (the daily quota, say) sends the key
* away for longer than `maxWaitMs`, the teams not reached yet are reported
* `retry_later` for the same time, without a request.
*
* Throws when the key itself is refused, when `/teams` cannot be read, when a
* team's answer points to a bug rather than to the team (a `400`, say), or
* when the network fails. No cursor is lost. When the error carries `retryAt`
* (the daily quota is spent, for example), schedule the next run for then.
*/
export async function syncKey(options: SyncOptions): Promise<TeamOutcome[]> {
const outcomes: TeamOutcome[] = []
const listed = await listedTeams(options)
for (const team of await options.store.teamIds()) {
if (listed.includes(team)) continue
await options.store.dropTeam(team)
outcomes.push({ team, status: 'dropped' })
}
// Set when a `429` sends the key away for longer than this run waits.
let keyWait: { code: string, retryAt: number } | null = null
for (const team of listed) {
if (keyWait) {
outcomes.push({ team, status: 'retry_later', ...keyWait })
continue
}
try {
outcomes.push({ team, status: await syncTeam({ ...options, teamId: team }) })
}
catch (error) {
if (!(error instanceof SyncHttpError)) throw error
const outcome = await outcomeOf(options, team, error)
outcomes.push(outcome)
// A `429` concerns the key, never one team: the daily quota and the rate
// limit are both the key's. The teams not reached yet would be refused
// too, so they wait for the same moment without spending a request.
if (error.status === 429 && outcome.status === 'retry_later') keyWait = { code: outcome.code, retryAt: outcome.retryAt }
}
}
return outcomes
}In-memory store for tests
MemorySyncStore implements every SyncStore method in memory. It shows what each method must do and makes your tests deterministic. It loses everything on restart, so replace it with your database before you go live.
/**
* An in-memory `SyncStore`, for tests and for reading alongside syncTeam.ts.
* It loses everything on restart: in production, implement every method on
* your database, durably, and commit a page and its cursor together when you can.
*/
import type { FeedItem, SyncStore } from './syncTeam.js'
/** One key per record: a lead and its two facets share an id but not a kind. */
const keyOf = (item: Pick<FeedItem, 'kind' | 'id'>) => `${item.kind}/${item.id}`
/**
* Every board, stage, service and member id an item names: a lead's `board`,
* `stage` and `assignees`, a conversation's `assignee`, a booking's `service`,
* and an activity's `stage_snapshot`, `from` and `to`: a stage with its board,
* or a list of members. A status change's `from` and `to` are statuses.
*/
function referencedIds(item: FeedItem): string[] {
const object = item.object ?? {}
const ids: unknown[] = [object.board, object.stage, object.service, object.assignee]
if (Array.isArray(object.assignees)) ids.push(...object.assignees)
if (item.kind === 'activity') {
for (const side of [object.from, object.to, object.stage_snapshot]) {
if (Array.isArray(side)) {
ids.push(...side)
}
else if (side && typeof side === 'object') {
const { stage, board } = side as { stage?: unknown, board?: unknown }
ids.push(stage, board)
}
}
}
return ids.filter((value): value is string => typeof value === 'string')
}
export class MemorySyncStore implements SyncStore {
/** team → record key → the latest item applied. */
rows = new Map<string, Map<string, FeedItem>>()
cursors = new Map<string, string>()
/** team → record key → version held, removals (tombstones) included. */
versions = new Map<string, Map<string, number>>()
/** `team/lead` → the lead's contact permissions. */
permissions = new Map<string, Record<string, unknown>>()
dictionaries = new Map<string, Record<string, unknown[]>>()
dictionaryTimes = new Map<string, number>()
/** team → keys the feed delivered during a running full resync. */
recovery = new Map<string, Set<string>>()
/** `team/lead` of every removed lead, so a facet or activity replayed later is not stored again. */
removedLeads = new Set<string>()
/** team → ids still unknown right after a dictionary refresh: departed members, deleted boards, stages or services. */
unresolved = new Map<string, Set<string>>()
async teamIds() {
return [...this.rows.keys()]
}
async ensureTeam(team: string) {
if (!this.rows.has(team)) this.rows.set(team, new Map())
if (!this.versions.has(team)) this.versions.set(team, new Map())
}
async cursor(team: string) {
return this.cursors.get(team) ?? null
}
async version(team: string, kind: string, id: string) {
return this.versions.get(team)?.get(`${kind}/${id}`) ?? 0
}
async setContactPermissions(team: string, lead: string, permissions: Record<string, unknown>) {
this.permissions.set(`${team}/${lead}`, structuredClone(permissions))
}
async upsert(team: string, item: FeedItem) {
await this.ensureTeam(team)
this.versions.get(team)!.set(keyOf(item), item.version)
// A facet or an activity of a removed lead keeps its version but is not stored.
const lead = item.kind === 'lead_attribution' || item.kind === 'lead_response' ? item.id : item.object?.lead
if (typeof lead === 'string' && this.removedLeads.has(`${team}/${lead}`)) return
this.rows.get(team)!.set(keyOf(item), structuredClone(item))
}
async remove(team: string, item: FeedItem) {
await this.ensureTeam(team)
// The tombstone: the version stays, so an older copy delivered later is ignored.
this.versions.get(team)!.set(keyOf(item), item.version)
const rows = this.rows.get(team)!
rows.delete(keyOf(item))
if (item.kind !== 'lead') return
// A removed lead takes its attribution, response, activities and permissions with it.
this.removedLeads.add(`${team}/${item.id}`)
this.permissions.delete(`${team}/${item.id}`)
for (const [key, row] of rows) {
const facet = (row.kind === 'lead_attribution' || row.kind === 'lead_response') && row.id === item.id
const activity = row.kind === 'activity' && row.object?.lead === item.id
if (facet || activity) rows.delete(key)
}
}
async saveCursor(team: string, cursor: string) {
this.cursors.set(team, cursor)
}
async startResync(team: string) {
this.versions.set(team, new Map())
this.cursors.delete(team)
this.recovery.set(team, new Set())
this.unresolved.delete(team)
// A restore can reissue ids and versions, so the resync starts from a clean slate.
for (const key of this.removedLeads) if (key.startsWith(`${team}/`)) this.removedLeads.delete(key)
}
async markSeen(team: string, item: FeedItem) {
this.recovery.get(team)?.add(keyOf(item))
}
async finishResync(team: string) {
const seen = this.recovery.get(team)
if (!seen) return
const rows = this.rows.get(team) ?? new Map<string, FeedItem>()
for (const [key, row] of rows) {
if (seen.has(key)) continue
rows.delete(key)
if (row.kind === 'lead') this.permissions.delete(`${team}/${row.id}`)
}
this.recovery.delete(team)
}
async dropTeam(team: string) {
this.rows.delete(team)
this.cursors.delete(team)
this.versions.delete(team)
this.recovery.delete(team)
this.dictionaries.delete(team)
this.dictionaryTimes.delete(team)
this.unresolved.delete(team)
for (const key of this.permissions.keys()) if (key.startsWith(`${team}/`)) this.permissions.delete(key)
for (const key of this.removedLeads) if (key.startsWith(`${team}/`)) this.removedLeads.delete(key)
}
async dictionariesAt(team: string) {
return this.dictionaryTimes.get(team) ?? null
}
async saveDictionaries(team: string, data: Record<string, unknown[]>, at: number) {
this.dictionaries.set(team, structuredClone(data))
this.dictionaryTimes.set(team, at)
}
async hasUnknownReferences(team: string, item: FeedItem) {
const unresolved = this.unresolved.get(team)
return this.unknownIds(team, item).some(id => !unresolved?.has(id))
}
async rememberUnresolved(team: string, item: FeedItem) {
const ids = this.unknownIds(team, item)
if (!ids.length) return
const unresolved = this.unresolved.get(team) ?? new Set<string>()
for (const id of ids) unresolved.add(id)
this.unresolved.set(team, unresolved)
}
/** The board, stage, service and member ids the item names that the dictionaries do not hold. */
private unknownIds(team: string, item: FeedItem): string[] {
if (!item.object) return []
const known = new Set<string>()
for (const entry of Object.values(this.dictionaries.get(team) ?? {}).flat()) {
const object = entry as { id: string, stages?: Array<{ id: string }> }
known.add(object.id)
for (const stage of object.stages ?? []) known.add(stage.id)
}
return referencedIds(item).filter(id => !known.has(id))
}
}