El feed de cambios, GET /v1/sync/changes, es la forma de tener tu propia copia de un equipo. Tu primera petición copia todos los registros. A partir de ahí, cada petición devuelve solo lo que ha cambiado desde la anterior, así que sincronizar cada pocos minutos cuesta poco y nunca se pierde un cambio.
- Empieza cada equipo sin cursor: tu primera petición a GET /v1/sync/changes inicia su carga inicial.
- Llama a GET /v1/sync/changes con el equipo en Totem-Team y tu cursor guardado.
- Aplica la página: omite cada elemento cuya versión ya tengas, guarda cada registro y borra cada registro eliminado, pero conserva su versión como marca de eliminación.
- Guarda next_cursor, solo cuando hayas guardado la página entera.
- Si has_more es true, vuelve a pedir enseguida. Si es false, estás al día: espera de 1 a 5 minutos y vuelve a pedir con el cursor guardado.
- Si la petición falla:
- 410 sync_cursor_expired o 409 resync_required: Resincronización completa: olvida las versiones, marca y barre.
- 409 capture_not_ready: Totem aún prepara el equipo. Reintenta en unos minutos.
- 403: Conserva los datos y el cursor. Vuelve a intentarlo más tarde.
- 404 team_not_accessible: Confírmalo con GET /v1/teams y después quita el equipo.
- 429: Espera lo que indique Retry-After y repite con el mismo cursor.
- 5xx: Reintenta con espera exponencial.
Cómo funciona el feed
Hay un feed por equipo. Una clave que lee varios equipos indica el equipo en la cabecera Totem-Team y guarda un cursor por equipo.
Cada elemento del feed es el estado actual de un registro, de un tipo (kind):
lead,lead_attributionylead_response: un lead y sus dos facetas;activity: un cambio de etapa, de estado o de miembros asignados de un lead;call,conversationybooking;board, con sus etapas dentro.
La atribución y la respuesta de un lead viajan como tipos propios, con el identificador del lead y su propia version. Por eso un mensaje nuevo cambia la respuesta del lead sin cambiar el lead. Los miembros y los servicios reservables no están en el feed: los lees aparte.
Los elementos llegan en el orden en que Totem registró los cambios, del más antiguo al más reciente. Un elemento nunca es una diferencia: trae el registro entero tal como está ahora.
Registros y eliminaciones
La mayoría de los elementos traen el registro en object. Es el mismo objeto que devuelven los endpoints de lista; aquí aparece abreviado:
{
"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
}
}Una eliminación trae removed en lugar de object. Te indica que borres el registro:
{
"kind": "call",
"id": "call_m1pf4APjJWVi3Z7ulyNaW8y3lUi7V",
"version": 5,
"removed": { "reason": "deleted" }
}reason es erased cuando el registro se suprimió: por privacidad, por ejemplo a petición de la persona, o porque alguien eliminó definitivamente en Totem un lead, una llamada o una conversación, lo que siempre los suprime. Es deleted cuando el registro desapareció de cualquier otra forma, por ejemplo porque ya no forma parte de los datos de la API del equipo o porque Totem lo identifica ahora con un identificador nuevo. Trata los dos casos igual, como explica Aplica una página. Un registro que vuelve es un registro nuevo con un identificador nuevo (Convenciones), así que relaciona a las personas entre identificadores por teléfono o correo electrónico con la búsqueda, nunca por identificador.
Planifica tu almacenamiento
Una copia construida así puede aplicar cada página sin riesgos, lleguen los registros en el orden que lleguen:
- Una fila por registro, con la clave formada por el equipo, el
kindy elid, y con suversion. Un lead y sus dos facetas comparten identificador, así que elkindforma parte de la clave. - Una marca por eliminación: el
kind, elidy laversionde un registro que borraste, sin sus datos. - Un cursor por equipo, guardado junto con la página a la que sigue cuando tu base de datos lo permita.
- Los tableros, los miembros y los servicios reservables en listas propias, que sustituyes enteras cada vez que las lees.
- Ninguna clave foránea entre registros mientras aplicas páginas. Un registro puede indicar otro que aún no te ha llegado, como un lead que indica una etapa de un tablero o una actividad que indica su lead.
Después, ejecuta una sincronización por clave cada 1 a 5 minutos, y vigila los equipos que se omiten o se aplazan una y otra vez, o cuyo cursor lleva un día sin guardarse.
Haz la carga inicial
Tu primera petición de un equipo no lleva cursor. Empieza la carga inicial: el feed recorre todos los registros del equipo, del cambio más antiguo al más reciente.
curl "https://api.totemstudio.ai/v1/sync/changes?limit=100" \
-H "Authorization: Bearer $TOTEM_API_KEY" \
-H "Totem-Team: $TOTEM_TEAM_ID"Aplica la página, guarda su next_cursor y vuelve a pedir con cursor igual a ese valor. Repite mientras has_more sea true. Cuando has_more sea false, tu copia estará completa. Pide la página más grande, de 100 elementos, para que la carga inicial necesite las menos peticiones posibles.
Durante una carga inicial hay dos cosas normales. Un registro que cambia mientras se ejecuta vuelve a llegar más tarde, con una versión mayor. Y, tras un reintento, puedes recibir la misma página dos veces. La comprobación de versiones del apartado siguiente hace que ninguna de las dos cause problemas.
Aplica una página
Para cada elemento de la página, en orden:
- Omite lo que ya tienes. Si ya tienes ese
kindeidcon la mismaversiono una mayor, omite el elemento. Compara también con las versiones de tus marcas de eliminación. - Guarda un registro. Si el elemento trae
object, inserta tu copia o sustitúyela. Si es un lead, guarda suscontact_permissionsantes de que nada pueda actuar sobre él. - Aplica una eliminación. Si el elemento trae
removed, borra tu copia, pero conserva sukind, suidy suversioncomo marca de eliminación. Si el registro eliminado es un lead, borra también su atribución, su respuesta y sus actividades, e ignora cualquiera de ellas que llegue después.
Guarda el cursor
Guarda next_cursor solo cuando hayas guardado la página entera. Si tu proceso se detiene entre medias, la siguiente ejecución vuelve a leer la misma página, y el paso 1 omite lo que ya aplicaste. Si tu base de datos lo permite, guarda la página y su cursor en una misma transacción.
Un cursor es opaco: guárdalo tal como lo recibiste y no construyas ni modifiques ninguno. Pertenece a un solo equipo. Si lo envías con otro equipo, recibe 400 invalid_request.
Un cursor caduca 30 días después del punto del feed al que llegó. Mientras una carga inicial sigue en curso, esa antigüedad se cuenta desde que empezó la carga. El cursor de un equipo que consultas con regularidad nunca llega a caducar.
Consulta los cambios periódicamente
Cuando has_more sea false, vuelve a pedir con el cursor guardado cada 1 a 5 minutos. Si no ha cambiado nada, recibes una página vacía con un next_cursor nuevo. Guárdalo también: una página vacía hace avanzar el cursor, así que el cursor de un equipo con poca actividad nunca envejece.
El feed retiene los últimos 10 segundos de cambios, para no saltarse nunca un cambio que aún se está escribiendo. Un cambio aparece en el feed poco después de producirse en Totem.
Sincroniza cada combinación de clave y equipo desde un solo proceso a la vez. Dos procesos que aplican páginas del mismo equipo pueden guardar sus cursores en un orden equivocado.
Eliminaciones, fusiones y traspasos
Eliminaciones
Aplica cada eliminación en cuanto la recibas: borra el registro de tu copia y de todos los sistemas a los que lo copiaste. Las eliminaciones siguen en el feed durante 45 días, más de lo que dura un cursor, así que una sincronización que sigue leyendo el feed con éxito nunca se pierde ninguna. Mientras la API rechaza un equipo, no entrega nada de él, tampoco eliminaciones; en Recupérate de los errores se explica qué hacer entonces.
Cuando se elimina un lead, su atribución, su respuesta y sus actividades se eliminan con él, y el feed entrega también sus eliminaciones. Lo que ocurre con los demás registros del lead depende de reason:
erased, también cuando alguien elimina definitivamente el lead en Totem: sus llamadas y conversaciones también se suprimen, igual que los leads archivados cuyomerged_intolo indica, con sus propias llamadas y conversaciones. Cada registro llega como una eliminación propia, conreason: "erased". Sus citas no se eliminan: vuelven a llegar conleadigual anull.deleted: sus llamadas, conversaciones y citas no se eliminan. Vuelven a llegar conleadigual anull.
Fusiones
Cuando en Totem se fusionan dos leads de la misma persona, el lead que no se conserva queda archivado, no eliminado. Su archive_reason es merged y su merged_into indica el lead que se conservó. Enlaza tu copia con ese lead en lugar de crear a la persona dos veces. El lead que se conserva se queda además con los permisos de contacto más restrictivos de los dos. merged_into es null cuando el lead que indicaba ya no existe.
Traspasos
Cuando un lead pasa a otro equipo de la misma organización, el lead original queda archivado con archive_reason igual a family_transfer, y transferred_to indica adónde fue:
transferred_to.teamsiempre tiene valor. Puede ser un equipo que tu clave no lee.transferred_to.leades el lead en ese equipo, onullcuando Totem no puede indicarlo.
Si tu clave lee el equipo de destino, el lead nuevo llega en el feed de ese equipo. Si no lo lee, GET /v1/leads/{id} sobre el lead de destino responde 404 resource_missing. Conserva el lead archivado como constancia de adónde fue la persona, y no intentes adivinarlo.
Primero, los permisos de contacto
Antes de que cualquiera de tus sistemas contacte con un lead, guarda sus contact_permissions y aplícalos:
do_not_contact: true: la persona pidió que no se la contacte nunca. No uses ningún canal.email_opt_out: true: la persona se dio de baja de los correos comerciales y de seguimiento.whatsapp_consent: false: la persona rechazó WhatsApp.nullsignifica que nadie registró su respuesta, así que nunca lo trates como un consentimiento.
Que un lead aparezca en el feed es un dato, no un permiso para contactar con la persona. La base legal para contactar con alguien sigue siendo responsabilidad tuya.
La API es de solo lectura, así que Totem no puede conocer una baja registrada en tus sistemas. Cuando una persona se dé de baja en ellos, asegúrate de que también quede registrada en el lead en Totem, para que Totem deje de contactarla.
Recupérate de los errores
Cuando falla la petición de una página, no guardes ningún cursor. La siguiente petición con el mismo cursor vuelve a pedir la misma página.
| Respuesta | Qué significa | Qué hacer |
|---|---|---|
410 sync_cursor_expired | El cursor tiene más de 30 días. | Haz una resincronización completa. |
409 resync_required | Totem no puede continuar desde este cursor, por ejemplo tras restaurar una copia de seguridad. | Haz una resincronización completa. |
409 capture_not_ready | Totem aún está preparando los datos de este equipo. Ocurre una vez, cuando un equipo se añade por primera vez a una clave. | Conserva todo y vuelve a intentarlo en unos minutos. Nunca resincronices por esto. Los demás equipos de la clave siguen sincronizándose. |
403 | El acceso está denegado por ahora: por ejemplo, el equipo está restringido (team_state_restricted) o el endpoint está desactivado (capability_disabled). | Conserva los datos y el cursor del equipo, y omítelo en esta ejecución. La página de Errores indica, código a código, si sirve de algo esperar. plan_not_entitled rechaza la clave entera, así que la ejecución se detiene en GET /v1/teams. Mientras el acceso está denegado, el feed no entrega nada del equipo, tampoco eliminaciones: si la denegación dura más de lo que permite tu política de conservación, borra la copia del equipo. |
404 team_not_accessible | Puede que la clave ya no lea este equipo. | Lee GET /v1/teams. Si el equipo no aparece, borra sus datos y su cursor. Si aparece, conserva todo y vuelve a intentarlo en la siguiente ejecución. |
400 invalid_request | El cursor se ha modificado o es de otro equipo. | Usa el cursor que guardaste para este equipo. |
429 | Has alcanzado el límite de frecuencia o la cuota diaria. | Espera los segundos que indique Retry-After y repite con el mismo cursor. Un 429 del cortafuegos no trae Retry-After: reintenta con espera exponencial. |
5xx | Algo ha fallado dentro de Totem. | Reintenta con espera exponencial. |
Todos los códigos se explican en la página de Errores.
Resincronización completa
Una resincronización completa reconstruye tu copia de un equipo marcando y barriendo:
- Olvida las versiones y el cursor que guardaste del equipo. Conserva los registros: tu copia sigue siendo utilizable mientras dura la resincronización.
- Repite la carga inicial, sin cursor. Aplica cada página como siempre y marca cada registro que entregue el feed.
- Cuando
has_moreseafalse, borra todos los registros del equipo que no hayas marcado.
Guarda las marcas de forma duradera. Si el proceso se detiene a medias, la siguiente ejecución debe continuar la misma resincronización, no barrer con la mitad de las marcas. También importa olvidar antes las versiones: después de una restauración, el feed puede entregar versiones más bajas que las que tenías.
Cuando cambian los equipos
Lee GET /v1/teams al principio de cada ejecución. Lista todos los equipos que lee tu clave, también los que están restringidos por ahora.
- Un equipo que aún no has sincronizado: haz su carga inicial.
- Un equipo que tienes y que ya no aparece: ha salido de la clave. Borra sus datos y su cursor.
- Un equipo que aparece pero responde
403: consérvalo, como se explica más arriba.
Actualiza tableros, miembros y servicios
Los miembros y los servicios reservables no están en el feed. Los tableros sí, pero un lead puede indicar una etapa nueva antes de que te llegue el cambio de su tablero. Por eso, lee los tres:
- cuando empieces a sincronizar un equipo;
- cuando un elemento indique un tablero, una etapa, un miembro o un servicio que no conoces;
- al menos una vez al día.
Un registro puede seguir indicando un miembro que salió del equipo, o un tablero, una etapa o un servicio que se borró. Si una actualización no encuentra un identificador, recuérdalo y no vuelvas a actualizar por él antes de la actualización diaria.
GET /v1/boards se pagina como cualquier lista. GET /v1/members y GET /v1/booking_services devuelven la lista entera en una sola respuesta y no admiten limit ni cursor. Sustituye entera tu copia de cada lista. Un miembro que deja el equipo, o un servicio que se borra, sale de su lista, pero los registros anteriores siguen indicando su identificador: si muestras nombres en registros anteriores, conserva el de una lectura anterior. Los miembros y servicios desactivados siguen en la lista, sin nada que indique que lo están.
Ritmo
Una clave lee hasta 2.000 elementos por minuto, así que la carga inicial de un equipo grande lleva un tiempo. En Límites de frecuencia y cuota tienes las cifras, una estimación de cuánto tarda una carga inicial y por qué las cifras son aproximadas durante la Beta. Si recibes un 429, espera lo que indique Retry-After y continúa con el mismo cursor: no se pierde nada. Los límites son de la clave, así que un 429 detiene todos los equipos que lee, no solo el que estabas sincronizando.
Cliente de referencia probado
El código TypeScript de más abajo es un cliente de sincronización completo que hace todo lo que describe esta página. Totem lo ejecuta en sus propias pruebas contra un feed simulado, con caídas, páginas repetidas, eliminaciones, resincronizaciones y errores; el código que se muestra aquí es ese mismo archivo, y por eso sus comentarios están en inglés. No tiene dependencias y solo necesita fetch: compílalo con tu build de TypeScript o ejecútalo directamente con Node.js 22.18 o posterior. Cópialo en tu servidor e implementa SyncStore sobre tu base de datos.
Ejecútalo desde una tarea programada cada 1 a 5 minutos, con una sola ejecución por clave a la vez. El ejemplo es un módulo ES; con la resolución de módulos de Node.js, importa el archivo con la extensión .js que genera tu build, o con .ts si Node.js ejecuta el TypeScript directamente:
import { syncKey } from './syncTeam.js'
const outcomes = await syncKey({ key: process.env.TOTEM_API_KEY!, store })La clave nunca debe llegar a un navegador. Cada método de SyncStore debe escribir de forma duradera antes de que se resuelva su promesa, y las escrituras de un mismo equipo no deben ejecutarse en paralelo.
Opciones y resultados
syncKey lee GET /v1/teams, borra los equipos que han salido de la clave y sincroniza, uno tras otro, todos los equipos de la lista. Devuelve un resultado por equipo:
synced: el equipo está al día y su cursor está guardado.partial: el equipo leyómaxPagesPerTeampáginas y todavía no está al día. Su cursor está guardado, y la siguiente ejecución continúa desde él.dropped: el equipo ha salido de la clave, y su copia y su cursor se han borrado.skipped, con elcode: ahora mismo no se puede leer el equipo (403,capture_not_readyo unteam_not_accessiblequeGET /v1/teamsno confirma). Se conservan su copia y su cursor, y la siguiente ejecución lo vuelve a intentar.retry_later, con elcodeyretryAt(milisegundos desde la época Unix): la API pidió esperar más de lo que permite esta ejecución, o un429o un5xxseguía fallando trasmaxRetriesreintentos. Se conservan la copia y el cursor. No vuelvas a sincronizar ese equipo antes deretryAt. Un429es de la clave, no de un equipo, así que en cuanto uno aparta la clave, todos los equipos a los que esta ejecución aún no ha llegado se marcanretry_latercon el mismocodey el mismoretryAt, sin hacer ninguna petición.
Un equipo restringido, todavía en preparación o que ya no está nunca detiene a los demás; un 429 que esta ejecución no puede esperar detiene la clave entera hasta retryAt. syncKey lanza un error cuando se rechaza la propia clave, cuando no se puede leer GET /v1/teams o ante un error que apunta a un fallo de programación y no al equipo: un 400, un 404 inesperado, un feed que vuelve a pedir una resincronización completa justo después de otra, una lista o un feed que repite un cursor o no envía ninguno, o un fallo de red. Tampoco entonces se pierde ningún cursor; si el error trae retryAt, programa la siguiente ejecución para ese momento.
Además de key y store, admite:
maxWaitMs: lo máximo que espera de una vez, en milisegundos. Por defecto, 60.000. UnRetry-Aftermás largo, como la espera hasta que la cuota diaria se renueva a las 00:00 UTC, termina la ejecución conretry_lateren lugar de dejar tu proceso dormido.maxRetries: cuántas veces se reintenta una petición tras un429o un5xx. Por defecto, 3.maxPagesPerTeam: el máximo de páginas del feed que lee un equipo en una ejecución. Por defecto no hay límite. Con un límite, una carga inicial larga se detiene conpartial, se sincronizan los demás equipos de la clave y la siguiente ejecución continúa desde el cursor guardado.fetch,baseUrl,sleepynow: sustitúyelos en tus pruebas.
/**
* 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
}Almacén en memoria para pruebas
MemorySyncStore implementa en memoria todos los métodos de SyncStore. Muestra lo que debe hacer cada método y hace que tus pruebas sean deterministas. Lo pierde todo al reiniciarse, así que sustitúyelo por tu base de datos antes de pasar a producción.
/**
* 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))
}
}