@critical-path/client 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { CriticalPathRouter } from '@critical-path/server';
3
+ import { CriticalPathClient, CriticalPathError } from './index.js';
4
+ function recordingFetch(respond) {
5
+ const calls = [];
6
+ const fetchImpl = (async (url, init) => {
7
+ const call = { url, method: init.method ?? 'GET', headers: init.headers };
8
+ calls.push(call);
9
+ return respond(call, calls.length);
10
+ });
11
+ return { calls, fetchImpl };
12
+ }
13
+ const ok = (body) => Response.json(body);
14
+ describe('client transport', () => {
15
+ it('throws CriticalPathError with status and server details', async () => {
16
+ const router = new CriticalPathRouter();
17
+ const client = new CriticalPathClient({
18
+ baseUrl: 'http://localhost/api/critical-path',
19
+ fetch: ((input, init) => router.handleRequest(new Request(input, init)))
20
+ });
21
+ const project = await client.createProject({ name: 'Errors' });
22
+ const a = await client.createTask({ projectId: project.id, title: 'A' });
23
+ const b = await client.createTask({ projectId: project.id, title: 'B' });
24
+ await client.addDependency(a.id, { dependsOnTaskId: b.id });
25
+ const cycle = await client.addDependency(b.id, { dependsOnTaskId: a.id }).catch((e) => e);
26
+ expect(cycle).toBeInstanceOf(CriticalPathError);
27
+ expect(cycle.status).toBe(409);
28
+ expect(cycle.body.cyclePath).toBeDefined();
29
+ const invalid = await client.createTask({ projectId: project.id, title: '' }).catch((e) => e);
30
+ expect(invalid.status).toBe(400);
31
+ expect(invalid.issues?.[0].path).toBe('title');
32
+ const missing = await client.getTask('nope').catch((e) => e);
33
+ expect(missing.isNotFound).toBe(true);
34
+ });
35
+ it('resolves the headers provider for every request and sends Content-Type only with a body', async () => {
36
+ let token = 0;
37
+ const { calls, fetchImpl } = recordingFetch(() => ok({ projects: [], project: { id: 'p' } }));
38
+ const client = new CriticalPathClient({
39
+ baseUrl: 'http://api.test',
40
+ fetch: fetchImpl,
41
+ headers: async () => ({ Authorization: `Bearer t${++token}` })
42
+ });
43
+ await client.getProjects();
44
+ await client.createProject({ name: 'x' });
45
+ expect(calls.map((c) => c.headers.Authorization)).toEqual(['Bearer t1', 'Bearer t2']);
46
+ expect(calls[0].headers['Content-Type']).toBeUndefined();
47
+ expect(calls[1].headers['Content-Type']).toBe('application/json');
48
+ });
49
+ it('encodes ids in paths', async () => {
50
+ const { calls, fetchImpl } = recordingFetch(() => ok({ task: {} }));
51
+ await new CriticalPathClient({ baseUrl: 'http://api.test', fetch: fetchImpl }).getTask('../projects/p1');
52
+ expect(calls[0].url).toBe('http://api.test/tasks/..%2Fprojects%2Fp1');
53
+ });
54
+ it('times out hung requests', async () => {
55
+ const hanging = ((_url, init) => new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(init.signal.reason))));
56
+ const client = new CriticalPathClient({ baseUrl: 'http://api.test', fetch: hanging, timeoutMs: 10 });
57
+ await expect(client.getProjects()).rejects.toMatchObject({ name: 'TimeoutError' });
58
+ });
59
+ it('scopes signals and headers with with(), leaving the original client untouched', async () => {
60
+ const { calls, fetchImpl } = recordingFetch(() => ok({ projects: [] }));
61
+ const client = new CriticalPathClient({ baseUrl: 'http://api.test', fetch: fetchImpl, headers: { 'X-App': 'web' } });
62
+ const controller = new AbortController();
63
+ controller.abort();
64
+ const aborting = ((_url, init) => Promise.reject(init.signal?.reason));
65
+ const cancelled = new CriticalPathClient({ baseUrl: 'http://api.test', fetch: aborting }).with({ signal: controller.signal });
66
+ await expect(cancelled.getProjects()).rejects.toMatchObject({ name: 'AbortError' });
67
+ await client.with({ headers: { 'X-Trace': 'abc' } }).getProjects();
68
+ await client.getProjects();
69
+ expect(calls[0].headers).toMatchObject({ 'X-App': 'web', 'X-Trace': 'abc' });
70
+ expect(calls[1].headers['X-Trace']).toBeUndefined();
71
+ });
72
+ it('retries GET requests on retryable failures but never writes', async () => {
73
+ const { calls, fetchImpl } = recordingFetch((_call, n) => (n < 3 ? new Response(null, { status: 503 }) : ok({ projects: [] })));
74
+ const client = new CriticalPathClient({ baseUrl: 'http://api.test', fetch: fetchImpl, retry: { retries: 2, baseDelayMs: 1 } });
75
+ await expect(client.getProjects()).resolves.toEqual([]);
76
+ expect(calls).toHaveLength(3);
77
+ const { calls: writes, fetchImpl: failingWrites } = recordingFetch(() => new Response(null, { status: 503 }));
78
+ const writer = new CriticalPathClient({ baseUrl: 'http://api.test', fetch: failingWrites, retry: { retries: 2, baseDelayMs: 1 } });
79
+ await expect(writer.createProject({ name: 'x' })).rejects.toBeInstanceOf(CriticalPathError);
80
+ expect(writes).toHaveLength(1);
81
+ });
82
+ it('does not retry client errors', async () => {
83
+ const fetchSpy = vi.fn(async () => Response.json({ error: 'nope' }, { status: 400 }));
84
+ const client = new CriticalPathClient({ baseUrl: 'http://api.test', fetch: fetchSpy, retry: { retries: 3 } });
85
+ await expect(client.getProjects()).rejects.toMatchObject({ status: 400 });
86
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
87
+ });
88
+ });
89
+ //# sourceMappingURL=transport.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.test.js","sourceRoot":"","sources":["../src/transport.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@critical-path/client",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Type-safe JavaScript/TypeScript client SDK for consuming Critical Path API endpoints",
5
5
  "author": "Jack James",
6
6
  "license": "MIT",
@@ -21,13 +21,13 @@
21
21
  }
22
22
  },
23
23
  "dependencies": {
24
- "@critical-path/core": "0.29.0"
24
+ "@critical-path/core": "0.30.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^24.0.0",
28
28
  "typescript": "^5.7.0",
29
29
  "vitest": "^3.0.0",
30
- "@critical-path/server": "0.21.0"
30
+ "@critical-path/server": "0.22.0"
31
31
  },
32
32
  "engines": {
33
33
  "node": ">=24.0.0"
@@ -68,6 +68,12 @@ describe('client ↔ server contract', () => {
68
68
  expect((await client.getWebhooks()).map((w) => w.id)).toContain(webhook.id);
69
69
  expect(await client.deleteWebhook(webhook.id)).toBe(true);
70
70
 
71
+ const page = await client.queryTasks({ projectId: project.id, limit: 1, parentId: null });
72
+ expect(page.items).toHaveLength(1);
73
+ expect(page.nextCursor).toBeTruthy();
74
+ expect((await client.getTasks(project.id)).length).toBeGreaterThan(1);
75
+ expect((await client.getActivities({ projectId: project.id })).length).toBeGreaterThan(0);
76
+
71
77
  expect(await client.deleteProject(project.id)).toBe(true);
72
78
  expect(await client.deleteProject(project.id)).toBe(false);
73
79
  });
package/src/index.ts CHANGED
@@ -25,6 +25,9 @@ import type {
25
25
  UpdateWebhookBody
26
26
  } from '@critical-path/core/schemas';
27
27
  import type {
28
+ ActivityQuery,
29
+ Page,
30
+ TaskQuery,
28
31
  PublicWebhook,
29
32
  TaskDependency,
30
33
  Project,
@@ -120,12 +123,68 @@ export interface UpdateStatusResult {
120
123
  timestamp: number;
121
124
  }
122
125
 
126
+ type HeaderMap = Record<string, string>;
127
+
123
128
  export interface ClientOptions {
124
- baseUrl: string; // e.g. "http://localhost:3000/api/critical-path"
125
- headers?: Record<string, string>;
129
+ /** API base URL, e.g. `http://localhost:3000/api/critical-path`. */
130
+ baseUrl: string;
131
+ /**
132
+ * Headers sent with every request. Pass a function to compute them per request, e.g. to attach
133
+ * a freshly refreshed access token: `headers: async () => ({ Authorization: `Bearer ${await getToken()}` })`.
134
+ */
135
+ headers?: HeaderMap | (() => HeaderMap | Promise<HeaderMap>);
126
136
  fetch?: typeof fetch;
137
+ /** Abort requests that take longer than this. Default: no timeout. */
138
+ timeoutMs?: number;
139
+ /**
140
+ * Retry GET requests that fail with a network error, 429, 502, 503 or 504. Default: no retries.
141
+ * Delays double from `baseDelayMs` (default 250 ms) and honour `Retry-After` on 429/503.
142
+ */
143
+ retry?: { retries: number; baseDelayMs?: number };
144
+ }
145
+
146
+ /** Per-call options for a scoped client (see `CriticalPathClient.with`). */
147
+ export interface RequestOptions {
148
+ signal?: AbortSignal;
149
+ headers?: HeaderMap;
150
+ timeoutMs?: number;
127
151
  }
128
152
 
153
+ /**
154
+ * Error thrown for non-2xx responses. Carries the HTTP status and the server's error details
155
+ * (`issues` for validation errors, `cyclePath` for dependency cycles, and so on).
156
+ */
157
+ export class CriticalPathError extends Error {
158
+ /** HTTP status code. */
159
+ readonly status: number;
160
+ /** Field-level validation problems for 400 responses. */
161
+ readonly issues?: Array<{ path: string; message: string }>;
162
+ /** The parsed JSON error body, if any (e.g. `fromStatus`, `toStatus`, `cyclePath`, `fieldKey`). */
163
+ readonly body?: Record<string, unknown>;
164
+
165
+ constructor(message: string, status: number, body?: Record<string, unknown>) {
166
+ super(message);
167
+ this.name = 'CriticalPathError';
168
+ this.status = status;
169
+ this.body = body;
170
+ this.issues = Array.isArray(body?.issues) ? (body!.issues as CriticalPathError['issues']) : undefined;
171
+ }
172
+
173
+ get isNotFound(): boolean {
174
+ return this.status === 404;
175
+ }
176
+ }
177
+
178
+ const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
179
+ const sleep = (ms: number, signal?: AbortSignal | null) =>
180
+ new Promise<void>((resolve, reject) => {
181
+ const timer = setTimeout(resolve, ms);
182
+ signal?.addEventListener('abort', () => {
183
+ clearTimeout(timer);
184
+ reject(signal.reason);
185
+ }, { once: true });
186
+ });
187
+
129
188
  function toBase64(data: Uint8Array | ArrayBuffer): string {
130
189
  const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
131
190
  let binary = '';
@@ -135,22 +194,32 @@ function toBase64(data: Uint8Array | ArrayBuffer): string {
135
194
  return btoa(binary);
136
195
  }
137
196
 
138
- class NotFoundResponseError extends Error {
139
- constructor(message: string) {
140
- super(message);
141
- this.name = 'NotFoundResponseError';
142
- }
143
- }
144
-
145
197
  export class CriticalPathClient {
146
- private baseUrl: string;
147
- private headers: Record<string, string>;
148
- private customFetch: typeof fetch;
198
+ private readonly baseUrl: string;
199
+ private readonly options: ClientOptions;
200
+ private readonly customFetch: typeof fetch;
201
+ /** Set on scoped clients created by `with()`. */
202
+ private readonly requestOptions?: RequestOptions;
149
203
 
150
204
  constructor(options: ClientOptions) {
205
+ this.options = options;
151
206
  this.baseUrl = options.baseUrl.replace(/\/+$/, '');
152
- this.headers = options.headers || {};
153
- this.customFetch = options.fetch || globalThis.fetch;
207
+ this.customFetch = options.fetch || ((...args) => globalThis.fetch(...args));
208
+ }
209
+
210
+ /**
211
+ * Returns a client that applies `options` to every call, e.g. to cancel requests when a
212
+ * component unmounts: `client.with({ signal: controller.signal }).getTasks(projectId)`.
213
+ */
214
+ with(options: RequestOptions): CriticalPathClient {
215
+ const scoped = Object.create(this) as CriticalPathClient;
216
+ const merged: RequestOptions = {
217
+ ...this.requestOptions,
218
+ ...options,
219
+ headers: { ...this.requestOptions?.headers, ...options.headers }
220
+ };
221
+ Object.defineProperty(scoped, 'requestOptions', { value: merged });
222
+ return scoped;
154
223
  }
155
224
 
156
225
  /**
@@ -162,36 +231,61 @@ export class CriticalPathClient {
162
231
  const res = await this.request<{ success: boolean }>(endpoint, { method: 'DELETE' });
163
232
  return res.success;
164
233
  } catch (err) {
165
- if (err instanceof NotFoundResponseError) return false;
234
+ if (err instanceof CriticalPathError && err.isNotFound) return false;
166
235
  throw err;
167
236
  }
168
237
  }
169
238
 
170
- private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
239
+ private async request<T>(endpoint: string, init: RequestInit = {}): Promise<T> {
171
240
  const url = `${this.baseUrl}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
172
- const response = await this.customFetch(url, {
173
- ...options,
174
- headers: {
175
- 'Content-Type': 'application/json',
176
- ...this.headers,
177
- ...options.headers
241
+ const method = (init.method ?? 'GET').toUpperCase();
242
+ const configured = typeof this.options.headers === 'function' ? await this.options.headers() : this.options.headers;
243
+ const headers: HeaderMap = {
244
+ ...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
245
+ ...configured,
246
+ ...this.requestOptions?.headers
247
+ };
248
+ const timeoutMs = this.requestOptions?.timeoutMs ?? this.options.timeoutMs;
249
+ const signals = [this.requestOptions?.signal, timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined].filter(
250
+ (s): s is AbortSignal => !!s
251
+ );
252
+ const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
253
+ const retries = method === 'GET' ? this.options.retry?.retries ?? 0 : 0;
254
+ const baseDelay = this.options.retry?.baseDelayMs ?? 250;
255
+
256
+ for (let attempt = 0; ; attempt++) {
257
+ let response: Response;
258
+ try {
259
+ response = await this.customFetch(url, { ...init, method, headers, signal });
260
+ } catch (err) {
261
+ // Network failure; never retry after an abort or timeout.
262
+ if (attempt < retries && !signal?.aborted) {
263
+ await sleep(baseDelay * 2 ** attempt, signal);
264
+ continue;
265
+ }
266
+ throw err;
178
267
  }
179
- });
180
268
 
181
- if (!response.ok) {
182
- let errorMsg = `HTTP Error ${response.status}: ${response.statusText}`;
269
+ if (response.ok) return response.json() as Promise<T>;
270
+
271
+ if (attempt < retries && RETRYABLE_STATUSES.has(response.status)) {
272
+ const retryAfter = Number(response.headers.get('Retry-After'));
273
+ await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : baseDelay * 2 ** attempt, signal);
274
+ continue;
275
+ }
276
+
277
+ let body: Record<string, unknown> | undefined;
183
278
  try {
184
- const errJson = await response.json();
185
- if (errJson.error) errorMsg = errJson.error;
279
+ body = await response.json();
186
280
  } catch {
187
- // Fallback to HTTP error
281
+ // Non-JSON error body
188
282
  }
189
- throw response.status === 404 ? new NotFoundResponseError(errorMsg) : new Error(errorMsg);
283
+ const message = typeof body?.error === 'string' ? body.error : `HTTP Error ${response.status}: ${response.statusText}`;
284
+ throw new CriticalPathError(message, response.status, body);
190
285
  }
191
-
192
- return response.json();
193
286
  }
194
287
 
288
+
195
289
  // Workflows
196
290
  async getWorkflows(): Promise<Workflow[]> {
197
291
  const res = await this.request<{ workflows: Workflow[] }>('/workflows');
@@ -199,7 +293,7 @@ export class CriticalPathClient {
199
293
  }
200
294
 
201
295
  async getWorkflow(id: string): Promise<Workflow> {
202
- const res = await this.request<{ workflow: Workflow }>(`/workflows/${id}`);
296
+ const res = await this.request<{ workflow: Workflow }>(`/workflows/${encodeURIComponent(id)}`);
203
297
  return res.workflow;
204
298
  }
205
299
 
@@ -212,7 +306,7 @@ export class CriticalPathClient {
212
306
  }
213
307
 
214
308
  async updateWorkflow(id: string, updates: UpdateWorkflowBody): Promise<Workflow> {
215
- const res = await this.request<{ workflow: Workflow }>(`/workflows/${id}`, {
309
+ const res = await this.request<{ workflow: Workflow }>(`/workflows/${encodeURIComponent(id)}`, {
216
310
  method: 'PATCH',
217
311
  body: JSON.stringify(updates)
218
312
  });
@@ -220,7 +314,7 @@ export class CriticalPathClient {
220
314
  }
221
315
 
222
316
  async deleteWorkflow(id: string): Promise<boolean> {
223
- return this.deleteRequest(`/workflows/${id}`);
317
+ return this.deleteRequest(`/workflows/${encodeURIComponent(id)}`);
224
318
  }
225
319
 
226
320
  // Projects
@@ -230,7 +324,7 @@ export class CriticalPathClient {
230
324
  }
231
325
 
232
326
  async getProject(id: string): Promise<Project> {
233
- const res = await this.request<{ project: Project }>(`/projects/${id}`);
327
+ const res = await this.request<{ project: Project }>(`/projects/${encodeURIComponent(id)}`);
234
328
  return res.project;
235
329
  }
236
330
 
@@ -303,14 +397,40 @@ export class CriticalPathClient {
303
397
  }
304
398
 
305
399
  // Tasks
400
+ /** All tasks (optionally in one project), following pagination. Prefer `queryTasks` for large lists. */
306
401
  async getTasks(projectId?: string): Promise<Task[]> {
307
- const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : '';
308
- const res = await this.request<{ tasks: Task[] }>(`/tasks${query}`);
309
- return res.tasks;
402
+ return this.collectPages((cursor) => this.queryTasks({ projectId, cursor, limit: 500 }));
403
+ }
404
+
405
+ /**
406
+ * One page of tasks, oldest first. Filters: `status` and `priority` (arrays), `assigneeId`,
407
+ * `iterationId`, `deliverableId`, `containerId`, `parentId` (`null` for top-level tasks).
408
+ */
409
+ async queryTasks(query: TaskQuery = {}): Promise<Page<Task>> {
410
+ const params = new URLSearchParams();
411
+ for (const [key, value] of Object.entries(query)) {
412
+ if (value === undefined || key === 'projectIds') continue;
413
+ if (key === 'parentId' && value === null) params.set('parentId', 'none');
414
+ else params.set(key, Array.isArray(value) ? value.join(',') : String(value));
415
+ }
416
+ const qs = params.size ? `?${params}` : '';
417
+ const res = await this.request<{ tasks: Task[]; nextCursor?: string }>(`/tasks${qs}`);
418
+ return { items: res.tasks, nextCursor: res.nextCursor };
419
+ }
420
+
421
+ private async collectPages<T>(fetchPage: (cursor?: string) => Promise<Page<T>>): Promise<T[]> {
422
+ const items: T[] = [];
423
+ let cursor: string | undefined;
424
+ do {
425
+ const page = await fetchPage(cursor);
426
+ items.push(...page.items);
427
+ cursor = page.nextCursor;
428
+ } while (cursor);
429
+ return items;
310
430
  }
311
431
 
312
432
  async getTask(id: string): Promise<Task> {
313
- const res = await this.request<{ task: Task }>(`/tasks/${id}`);
433
+ const res = await this.request<{ task: Task }>(`/tasks/${encodeURIComponent(id)}`);
314
434
  return res.task;
315
435
  }
316
436
 
@@ -344,7 +464,7 @@ export class CriticalPathClient {
344
464
  }
345
465
 
346
466
  async updateTask(id: string, updates: UpdateTaskBody): Promise<Task> {
347
- const res = await this.request<{ task: Task }>(`/tasks/${id}`, {
467
+ const res = await this.request<{ task: Task }>(`/tasks/${encodeURIComponent(id)}`, {
348
468
  method: 'PATCH',
349
469
  body: JSON.stringify(updates)
350
470
  });
@@ -421,6 +541,10 @@ export class CriticalPathClient {
421
541
  return (await this.request<{ webhooks: PublicWebhook[] }>('/webhooks')).webhooks;
422
542
  }
423
543
 
544
+ async getWebhook(id: string): Promise<PublicWebhook> {
545
+ return (await this.request<{ webhook: PublicWebhook }>(`/webhooks/${encodeURIComponent(id)}`)).webhook;
546
+ }
547
+
424
548
  /** Registers a webhook. The returned `secret` is shown only once; store it to verify deliveries. */
425
549
  async createWebhook(data: CreateWebhookBody): Promise<{ webhook: PublicWebhook; secret: string }> {
426
550
  return this.request<{ webhook: PublicWebhook; secret: string }>('/webhooks', {
@@ -490,7 +614,7 @@ export class CriticalPathClient {
490
614
  }
491
615
 
492
616
  async getTeam(id: string): Promise<Team> {
493
- const res = await this.request<{ team: Team }>(`/teams/${id}`);
617
+ const res = await this.request<{ team: Team }>(`/teams/${encodeURIComponent(id)}`);
494
618
  return res.team;
495
619
  }
496
620
 
@@ -503,7 +627,7 @@ export class CriticalPathClient {
503
627
  }
504
628
 
505
629
  async updateTeam(id: string, updates: UpdateTeamBody): Promise<Team> {
506
- const res = await this.request<{ team: Team }>(`/teams/${id}`, {
630
+ const res = await this.request<{ team: Team }>(`/teams/${encodeURIComponent(id)}`, {
507
631
  method: 'PATCH',
508
632
  body: JSON.stringify(updates)
509
633
  });
@@ -511,7 +635,7 @@ export class CriticalPathClient {
511
635
  }
512
636
 
513
637
  async deleteTeam(id: string): Promise<boolean> {
514
- return this.deleteRequest(`/teams/${id}`);
638
+ return this.deleteRequest(`/teams/${encodeURIComponent(id)}`);
515
639
  }
516
640
 
517
641
  // Containers
@@ -520,6 +644,10 @@ export class CriticalPathClient {
520
644
  return res.containers;
521
645
  }
522
646
 
647
+ async getContainer(id: string): Promise<TaskContainer> {
648
+ return (await this.request<{ container: TaskContainer }>(`/containers/${encodeURIComponent(id)}`)).container;
649
+ }
650
+
523
651
  async createContainer(data: CreateContainerBody): Promise<TaskContainer> {
524
652
  const res = await this.request<{ container: TaskContainer }>('/containers', {
525
653
  method: 'POST',
@@ -529,7 +657,7 @@ export class CriticalPathClient {
529
657
  }
530
658
 
531
659
  async updateContainer(id: string, updates: UpdateContainerBody): Promise<TaskContainer> {
532
- const res = await this.request<{ container: TaskContainer }>(`/containers/${id}`, {
660
+ const res = await this.request<{ container: TaskContainer }>(`/containers/${encodeURIComponent(id)}`, {
533
661
  method: 'PATCH',
534
662
  body: JSON.stringify(updates)
535
663
  });
@@ -537,7 +665,7 @@ export class CriticalPathClient {
537
665
  }
538
666
 
539
667
  async deleteContainer(id: string): Promise<boolean> {
540
- return this.deleteRequest(`/containers/${id}`);
668
+ return this.deleteRequest(`/containers/${encodeURIComponent(id)}`);
541
669
  }
542
670
 
543
671
  // Deliverables
@@ -547,12 +675,12 @@ export class CriticalPathClient {
547
675
  }
548
676
 
549
677
  async getDeliverable(id: string): Promise<Deliverable> {
550
- const res = await this.request<{ deliverable: Deliverable }>(`/deliverables/${id}`);
678
+ const res = await this.request<{ deliverable: Deliverable }>(`/deliverables/${encodeURIComponent(id)}`);
551
679
  return res.deliverable;
552
680
  }
553
681
 
554
682
  async getDeliverableSummary(id: string): Promise<DeliverableSummary> {
555
- const res = await this.request<{ summary: DeliverableSummary }>(`/deliverables/${id}/summary`);
683
+ const res = await this.request<{ summary: DeliverableSummary }>(`/deliverables/${encodeURIComponent(id)}/summary`);
556
684
  return res.summary;
557
685
  }
558
686
 
@@ -565,7 +693,7 @@ export class CriticalPathClient {
565
693
  }
566
694
 
567
695
  async updateDeliverable(id: string, updates: UpdateDeliverableBody): Promise<Deliverable> {
568
- const res = await this.request<{ deliverable: Deliverable }>(`/deliverables/${id}`, {
696
+ const res = await this.request<{ deliverable: Deliverable }>(`/deliverables/${encodeURIComponent(id)}`, {
569
697
  method: 'PATCH',
570
698
  body: JSON.stringify(updates)
571
699
  });
@@ -573,7 +701,7 @@ export class CriticalPathClient {
573
701
  }
574
702
 
575
703
  async deleteDeliverable(id: string): Promise<boolean> {
576
- return this.deleteRequest(`/deliverables/${id}`);
704
+ return this.deleteRequest(`/deliverables/${encodeURIComponent(id)}`);
577
705
  }
578
706
 
579
707
  // Iterations
@@ -582,6 +710,10 @@ export class CriticalPathClient {
582
710
  return res.iterations;
583
711
  }
584
712
 
713
+ async getIteration(id: string): Promise<Iteration> {
714
+ return (await this.request<{ iteration: Iteration }>(`/iterations/${encodeURIComponent(id)}`)).iteration;
715
+ }
716
+
585
717
  async createIteration(data: CreateIterationBody): Promise<Iteration> {
586
718
  const res = await this.request<{ iteration: Iteration }>('/iterations', {
587
719
  method: 'POST',
@@ -591,7 +723,7 @@ export class CriticalPathClient {
591
723
  }
592
724
 
593
725
  async updateIteration(id: string, updates: UpdateIterationBody): Promise<Iteration> {
594
- const res = await this.request<{ iteration: Iteration }>(`/iterations/${id}`, {
726
+ const res = await this.request<{ iteration: Iteration }>(`/iterations/${encodeURIComponent(id)}`, {
595
727
  method: 'PATCH',
596
728
  body: JSON.stringify(updates)
597
729
  });
@@ -599,17 +731,24 @@ export class CriticalPathClient {
599
731
  }
600
732
 
601
733
  async deleteIteration(id: string): Promise<boolean> {
602
- return this.deleteRequest(`/iterations/${id}`);
734
+ return this.deleteRequest(`/iterations/${encodeURIComponent(id)}`);
603
735
  }
604
736
 
605
737
  // Activity Stream
738
+ /** The whole activity feed (newest first), following pagination. Prefer `queryActivities` for feeds. */
606
739
  async getActivities(filter?: { projectId?: string; taskId?: string }): Promise<Activity[]> {
740
+ return this.collectPages((cursor) => this.queryActivities({ ...filter, cursor, limit: 500 }));
741
+ }
742
+
743
+ /** One page of the activity feed, newest first. */
744
+ async queryActivities(query: ActivityQuery = {}): Promise<Page<Activity>> {
607
745
  const params = new URLSearchParams();
608
- if (filter?.projectId) params.set('projectId', filter.projectId);
609
- if (filter?.taskId) params.set('taskId', filter.taskId);
610
- const query = params.toString() ? `?${params.toString()}` : '';
611
- const res = await this.request<{ activities: Activity[] }>(`/activities${query}`);
612
- return res.activities;
746
+ for (const [key, value] of Object.entries(query)) {
747
+ if (value !== undefined && key !== 'projectIds') params.set(key, String(value));
748
+ }
749
+ const qs = params.size ? `?${params}` : '';
750
+ const res = await this.request<{ activities: Activity[]; nextCursor?: string }>(`/activities${qs}`);
751
+ return { items: res.activities, nextCursor: res.nextCursor };
613
752
  }
614
753
 
615
754
  // Comments