// ── SDK v2 — ActivitiesClient ────────────────────────────────────────────────── // // Covers: // GET /v2/activities — list (cursor paginated) // POST /v2/activities — create single // POST /v2/activities/bulk — create many // GET /v2/activities/sse — real-time SSE stream import type { Fetcher } from "../http/fetcher"; import type { RequestOptions } from "../http/types"; import type { Activity, ActivityCreate, ListParams, Paginated } from "../types/index"; // ── SSE types ───────────────────────────────────────────────────────────────── /** Options for `ActivitiesClient.stream()`. */ export interface StreamOptions { /** DSL filter applied server-side — only matching activities are streamed. */ q?: string; /** Field projection — limits which fields are included in each event. */ fields?: string; /** AbortSignal to close the stream. Pass `controller.signal` to cancel. */ signal?: AbortSignal; /** Called for each activity received from the stream. */ onActivity: (activity: Activity) => void; /** Called when the stream encounters a parse or transport error. */ onError?: (err: Error) => void; /** Called when the stream closes (signal aborted or server closed). */ onClose?: () => void; } /** Return value of `stream()` — use `cancel()` to close the connection. */ export interface StreamHandle { /** Aborts the underlying fetch request and closes the SSE connection. */ cancel: () => void; } // ── Helpers ──────────────────────────────────────────────────────────────────── /** * Parses a raw SSE line buffer into Activity objects. * SSE format: lines starting with "data: " carry JSON payloads. * Lines starting with "event:" carry the event name (we accept all events). * Empty lines separate events. */ function* parseSseChunk(chunk: string): Generator { const lines = chunk.split("\n"); for (const line of lines) { if (!line.startsWith("data: ")) continue; const raw = line.slice(6).trim(); if (!raw || raw === "[DONE]") continue; try { yield JSON.parse(raw) as Activity; } catch { // malformed JSON — skip silently } } } // ── ActivitiesClient ─────────────────────────────────────────────────────────── export class ActivitiesClient { constructor(private readonly fetcher: Fetcher) {} // ── REST ───────────────────────────────────────────────────────────────────── /** * Lists activities with cursor pagination and optional DSL filtering. * * @example * ```ts * const page = await client.activities.list({ * q: "card_id.eq:'card-abc'", * sort: "created_at:desc", * limit: 50, * }); * ``` */ list(params?: ListParams, opts?: RequestOptions): Promise> { return this.fetcher.list("/activities", params, opts); } /** * Creates a single activity. * * @example * ```ts * const activity = await client.activities.create({ * team_id: "team-abc", * type: "card_status_changed", * description: "Status changed to done", * card_id: "card-123", * actor: { id: "user-1", type: "user", name: "Alice" }, * }); * ``` */ create(data: ActivityCreate, opts?: RequestOptions): Promise { return this.fetcher.post("/activities", data, opts); } /** * Bulk-creates multiple activities in a single request. * More efficient than calling `create()` in a loop. * * @example * ```ts * const created = await client.activities.bulkCreate([ * { team_id: "t", type: "card_created", description: "...", ... }, * { team_id: "t", type: "card_member_added", description: "...", ... }, * ]); * ``` */ bulkCreate(items: ActivityCreate[], opts?: RequestOptions): Promise { return this.fetcher.post("/activities/bulk", { items }, opts); } // ── SSE streaming ───────────────────────────────────────────────────────────── /** * Opens a real-time SSE stream of activities. * * Activities are pushed server-side as soon as they are created. * The stream runs until `cancel()` is called or the optional `signal` is aborted. * * Works in **Node 18+** (native fetch streaming) and **all modern browsers**. * For Node < 18 inject a `fetch` that supports streaming via `ClientConfig.fetch`. * * @example * ```ts * const { cancel } = client.activities.stream({ * q: `board_id.eq:'${boardId}'`, * onActivity: (activity) => { * console.log(activity.type, activity.description); * }, * onError: (err) => console.error("SSE error:", err), * onClose: () => console.log("Stream closed"), * }); * * // Later — close the connection * cancel(); * ``` * * @example With AbortController: * ```ts * const ctrl = new AbortController(); * client.activities.stream({ signal: ctrl.signal, onActivity: handler }); * // Cancel from outside * ctrl.abort(); * ``` */ stream(options: StreamOptions): StreamHandle { const ctrl = new AbortController(); // Merge caller's signal with our internal controller if (options.signal) { options.signal.addEventListener("abort", () => ctrl.abort(), { once: true }); } // Fire-and-forget async loop — errors surface via onError void this.#runStream(ctrl.signal, options); return { cancel: () => ctrl.abort(), }; } // ── Private SSE loop ────────────────────────────────────────────────────────── async #runStream(signal: AbortSignal, options: StreamOptions): Promise { const query: Record = {}; if (options.q) query["q"] = options.q; if (options.fields) query["fields"] = options.fields; let response: Response; try { response = await this.fetcher.stream("/activities/sse", query, { signal }); } catch (err) { if (signal.aborted) { options.onClose?.(); return; } options.onError?.(err instanceof Error ? err : new Error(String(err))); options.onClose?.(); return; } // Read the body as a text stream using the WHATWG Streams API // (available in Node 18+ via fetch, and all modern browsers) const reader = response.body?.getReader(); const decoder = new TextDecoder(); let buffer = ""; if (!reader) { options.onError?.(new Error("Response body is not readable")); options.onClose?.(); return; } try { while (true) { if (signal.aborted) break; const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE events are separated by double newlines const parts = buffer.split("\n\n"); // Last part may be incomplete — keep it in the buffer buffer = parts.pop() ?? ""; for (const part of parts) { for (const activity of parseSseChunk(part)) { try { options.onActivity(activity); } catch (handlerErr) { options.onError?.( handlerErr instanceof Error ? handlerErr : new Error(String(handlerErr)), ); } } } } } catch (err) { if (!signal.aborted) { options.onError?.(err instanceof Error ? err : new Error(String(err))); } } finally { reader.releaseLock(); options.onClose?.(); } } }