@critical-path/core 0.28.0 → 0.30.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +14 -0
  3. package/dist/domain/events.d.ts +8 -2
  4. package/dist/domain/events.d.ts.map +1 -1
  5. package/dist/domain/events.js +1 -0
  6. package/dist/domain/events.js.map +1 -1
  7. package/dist/engine/cascade.test.d.ts +2 -0
  8. package/dist/engine/cascade.test.d.ts.map +1 -0
  9. package/dist/engine/cascade.test.js +99 -0
  10. package/dist/engine/cascade.test.js.map +1 -0
  11. package/dist/engine/index.d.ts +25 -2
  12. package/dist/engine/index.d.ts.map +1 -1
  13. package/dist/engine/index.js +117 -6
  14. package/dist/engine/index.js.map +1 -1
  15. package/dist/schemas/index.d.ts +81 -28
  16. package/dist/schemas/index.d.ts.map +1 -1
  17. package/dist/schemas/index.js +26 -0
  18. package/dist/schemas/index.js.map +1 -1
  19. package/dist/store/firebase.d.ts +12 -0
  20. package/dist/store/firebase.d.ts.map +1 -1
  21. package/dist/store/firebase.js +40 -8
  22. package/dist/store/firebase.js.map +1 -1
  23. package/dist/store/index.d.ts +15 -0
  24. package/dist/store/index.d.ts.map +1 -1
  25. package/dist/store/index.js +19 -0
  26. package/dist/store/index.js.map +1 -1
  27. package/dist/store/query.d.ts +50 -0
  28. package/dist/store/query.d.ts.map +1 -0
  29. package/dist/store/query.js +79 -0
  30. package/dist/store/query.js.map +1 -0
  31. package/dist/store/query.test.d.ts +2 -0
  32. package/dist/store/query.test.d.ts.map +1 -0
  33. package/dist/store/query.test.js +89 -0
  34. package/dist/store/query.test.js.map +1 -0
  35. package/dist/store/sqlite.d.ts +6 -0
  36. package/dist/store/sqlite.d.ts.map +1 -1
  37. package/dist/store/sqlite.js +131 -24
  38. package/dist/store/sqlite.js.map +1 -1
  39. package/dist/webhooks/webhooks.test.js +2 -1
  40. package/dist/webhooks/webhooks.test.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/domain/events.ts +7 -0
  43. package/src/engine/cascade.test.ts +121 -0
  44. package/src/engine/index.ts +126 -6
  45. package/src/schemas/index.ts +31 -0
  46. package/src/store/firebase.ts +47 -8
  47. package/src/store/index.ts +40 -0
  48. package/src/store/query.test.ts +110 -0
  49. package/src/store/query.ts +113 -0
  50. package/src/store/sqlite.ts +149 -31
  51. package/src/webhooks/webhooks.test.ts +2 -1
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -11,6 +11,7 @@
11
11
  import { z } from 'zod';
12
12
  import { ValidationError, type ValidationIssue } from '../domain/errors.js';
13
13
  import { DOMAIN_EVENT_NAMES } from '../domain/events.js';
14
+ import { MAX_PAGE_SIZE } from '../store/query.js';
14
15
 
15
16
  /**
16
17
  * Parses `data` with `schema`, returning the cleaned value (unknown keys stripped, defaults
@@ -324,6 +325,35 @@ export const LogTimeSchema = strictObject({
324
325
  loggedAt: isoString.optional()
325
326
  });
326
327
 
328
+ // --- List queries (URL search parameters) ---
329
+
330
+ const csv = z
331
+ .string()
332
+ .transform((value) => value.split(',').map((part) => part.trim()).filter(Boolean));
333
+ const pageLimit = z.coerce.number().int().min(1).max(MAX_PAGE_SIZE);
334
+
335
+ /** `GET /tasks` query parameters. `status` and `priority` are comma-separated; `parentId=none` means top-level. */
336
+ export const TaskListQuerySchema = strictObject({
337
+ projectId: nonEmpty.optional(),
338
+ status: csv.optional(),
339
+ priority: csv.optional(),
340
+ assigneeId: nonEmpty.optional(),
341
+ iterationId: nonEmpty.optional(),
342
+ deliverableId: nonEmpty.optional(),
343
+ containerId: nonEmpty.optional(),
344
+ parentId: nonEmpty.transform((value) => (value === 'none' ? null : value)).optional(),
345
+ limit: pageLimit.optional(),
346
+ cursor: nonEmpty.optional()
347
+ });
348
+
349
+ /** `GET /activities` query parameters. */
350
+ export const ActivityListQuerySchema = strictObject({
351
+ projectId: nonEmpty.optional(),
352
+ taskId: nonEmpty.optional(),
353
+ limit: pageLimit.optional(),
354
+ cursor: nonEmpty.optional()
355
+ });
356
+
327
357
  // --- Webhooks ---
328
358
 
329
359
  export const CreateWebhookSchema = strictObject({
@@ -367,5 +397,6 @@ export type UploadAttachmentBody = z.input<typeof UploadAttachmentSchema>;
367
397
  export type PresignAttachmentBody = z.input<typeof PresignAttachmentSchema>;
368
398
  export type LogTimeBody = z.input<typeof LogTimeSchema>;
369
399
  export type CreateWebhookBody = z.input<typeof CreateWebhookSchema>;
400
+ export type TaskListQueryParams = z.input<typeof TaskListQuerySchema>;
370
401
  export type UpdateWebhookBody = z.input<typeof UpdateWebhookSchema>;
371
402
 
@@ -1,4 +1,5 @@
1
1
  import type { StorageAdapter } from './index.js';
2
+ import { matchesActivityQuery, matchesTaskQuery, paginate, type ActivityQuery, type Page, type TaskQuery } from './query.js';
2
3
  import type {
3
4
  Project,
4
5
  Task,
@@ -104,6 +105,10 @@ export interface FirebaseStoreConfig {
104
105
  db: FirestoreDBInterface;
105
106
  }
106
107
 
108
+ /**
109
+ * Removes `undefined` values, which Firestore rejects. Update methods write the complete merged
110
+ * record without `{ merge: true }`, so a field cleared with `undefined` is actually removed.
111
+ */
107
112
  export function sanitizeFirestoreData<T>(obj: T): T {
108
113
  if (obj === null || typeof obj !== 'object') {
109
114
  return obj;
@@ -162,7 +167,7 @@ export class FirebaseStore implements StorageAdapter {
162
167
  ...updates,
163
168
  updatedAt: new Date().toISOString()
164
169
  };
165
- await this.db.collection('projects').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
170
+ await this.db.collection('projects').doc(id).set(sanitizeFirestoreData(updated));
166
171
  return updated;
167
172
  }
168
173
 
@@ -202,7 +207,7 @@ export class FirebaseStore implements StorageAdapter {
202
207
  ...updates,
203
208
  updatedAt: new Date().toISOString()
204
209
  };
205
- await this.db.collection('workflows').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
210
+ await this.db.collection('workflows').doc(id).set(sanitizeFirestoreData(updated));
206
211
  return updated;
207
212
  }
208
213
 
@@ -238,7 +243,7 @@ export class FirebaseStore implements StorageAdapter {
238
243
  if (!existing) return null;
239
244
 
240
245
  const updated: Team = { ...existing, ...updates, updatedAt: new Date().toISOString() };
241
- await this.db.collection('teams').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
246
+ await this.db.collection('teams').doc(id).set(sanitizeFirestoreData(updated));
242
247
  return updated;
243
248
  }
244
249
 
@@ -274,7 +279,7 @@ export class FirebaseStore implements StorageAdapter {
274
279
  if (!existing) return null;
275
280
 
276
281
  const updated: TaskContainer = { ...existing, ...updates, updatedAt: new Date().toISOString() };
277
- await this.db.collection('containers').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
282
+ await this.db.collection('containers').doc(id).set(sanitizeFirestoreData(updated));
278
283
  return updated;
279
284
  }
280
285
 
@@ -318,7 +323,7 @@ export class FirebaseStore implements StorageAdapter {
318
323
  if (!existing) return null;
319
324
 
320
325
  const updated: Deliverable = { ...existing, ...updates, updatedAt: new Date().toISOString() };
321
- await this.db.collection('deliverables').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
326
+ await this.db.collection('deliverables').doc(id).set(sanitizeFirestoreData(updated));
322
327
  return updated;
323
328
  }
324
329
 
@@ -340,6 +345,12 @@ export class FirebaseStore implements StorageAdapter {
340
345
  return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
341
346
  }
342
347
 
348
+ /** Pushes down the project filter; remaining filters and pagination are applied in memory. */
349
+ async queryTasks(query: TaskQuery): Promise<Page<Task>> {
350
+ const tasks = (await this.getTasks(query.projectId)).filter((t) => matchesTaskQuery(t, query));
351
+ return paginate(tasks, (t) => t.createdAt, 'asc', query.limit, query.cursor);
352
+ }
353
+
343
354
  async getTask(id: string): Promise<Task | null> {
344
355
  const snap = await this.db.collection('tasks').doc(id).get();
345
356
  if (!snap.exists) return null;
@@ -363,7 +374,7 @@ export class FirebaseStore implements StorageAdapter {
363
374
  ...updates,
364
375
  updatedAt: new Date().toISOString()
365
376
  };
366
- await this.db.collection('tasks').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
377
+ await this.db.collection('tasks').doc(id).set(sanitizeFirestoreData(updated));
367
378
  return updated;
368
379
  }
369
380
 
@@ -399,7 +410,7 @@ export class FirebaseStore implements StorageAdapter {
399
410
  if (!snap.exists) return null;
400
411
 
401
412
  const updated: Iteration = { ...snap.data(), id, ...updates };
402
- await this.db.collection('iterations').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
413
+ await this.db.collection('iterations').doc(id).set(sanitizeFirestoreData(updated));
403
414
  return updated;
404
415
  }
405
416
 
@@ -439,7 +450,7 @@ export class FirebaseStore implements StorageAdapter {
439
450
  ...updates,
440
451
  updatedAt: new Date().toISOString()
441
452
  };
442
- await this.db.collection('comments').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
453
+ await this.db.collection('comments').doc(id).set(sanitizeFirestoreData(updated));
443
454
  return updated;
444
455
  }
445
456
 
@@ -522,6 +533,13 @@ export class FirebaseStore implements StorageAdapter {
522
533
  return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
523
534
  }
524
535
 
536
+ async queryActivities(query: ActivityQuery): Promise<Page<Activity>> {
537
+ const activities = (await this.getActivities({ projectId: query.projectId, taskId: query.taskId })).filter((a) =>
538
+ matchesActivityQuery(a, query)
539
+ );
540
+ return paginate(activities, (a) => a.createdAt, 'desc', query.limit, query.cursor);
541
+ }
542
+
525
543
  async logActivity(activity: Omit<Activity, 'id' | 'createdAt'>): Promise<Activity> {
526
544
  const docRef = this.db.collection('activities').doc();
527
545
  const now = new Date().toISOString();
@@ -567,6 +585,27 @@ export class FirebaseStore implements StorageAdapter {
567
585
  return newDep;
568
586
  }
569
587
 
588
+ async getDependency(id: string): Promise<TaskDependency | null> {
589
+ const doc = await this.db.collection('dependencies').doc(id).get();
590
+ return doc.exists ? ({ ...doc.data(), id: doc.id } as TaskDependency) : null;
591
+ }
592
+
593
+ async removeDependency(id: string): Promise<boolean> {
594
+ return this.deleteDoc('dependencies', id);
595
+ }
596
+
597
+ async deleteTimeEntry(id: string): Promise<boolean> {
598
+ return this.deleteDoc('time_entries', id);
599
+ }
600
+
601
+ private async deleteDoc(collection: string, id: string): Promise<boolean> {
602
+ const ref = this.db.collection(collection).doc(id);
603
+ const doc = await ref.get();
604
+ if (!doc.exists) return false;
605
+ await ref.delete();
606
+ return true;
607
+ }
608
+
570
609
  // --- Webhooks ---
571
610
  async getWebhooks(): Promise<Webhook[]> {
572
611
  const snap = await this.db.collection('webhooks').get();
@@ -18,6 +18,16 @@ import type {
18
18
  CreateDeliverableInput
19
19
  } from '../types/index.js';
20
20
  import { generateProjectKey } from '../utils/key.js';
21
+ import {
22
+ matchesActivityQuery,
23
+ matchesTaskQuery,
24
+ paginate,
25
+ type ActivityQuery,
26
+ type Page,
27
+ type TaskQuery
28
+ } from './query.js';
29
+
30
+ export * from './query.js';
21
31
 
22
32
  export interface ProjectRepository {
23
33
  getProjects(): Promise<Project[]>;
@@ -37,6 +47,8 @@ export interface WorkflowRepository {
37
47
 
38
48
  export interface TaskRepository {
39
49
  getTasks(projectId?: string): Promise<Task[]>;
50
+ /** Filtered, paginated tasks ordered by `(createdAt, id)` ascending. */
51
+ queryTasks(query: TaskQuery): Promise<Page<Task>>;
40
52
  getTask(id: string): Promise<Task | null>;
41
53
  createTask(task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<Task>;
42
54
  updateTask(id: string, updates: Partial<Task>): Promise<Task | null>;
@@ -86,17 +98,23 @@ export interface AttachmentRepository {
86
98
 
87
99
  export interface ActivityRepository {
88
100
  getActivities(filter?: { projectId?: string; taskId?: string }): Promise<Activity[]>;
101
+ /** Paginated activity feed ordered by `(createdAt, id)` descending (newest first). */
102
+ queryActivities(query: ActivityQuery): Promise<Page<Activity>>;
89
103
  logActivity(activity: Omit<Activity, 'id' | 'createdAt'>): Promise<Activity>;
90
104
  }
91
105
 
92
106
  export interface TimeEntryRepository {
93
107
  getTimeEntries(taskId: string): Promise<TimeEntry[]>;
94
108
  logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string }): Promise<TimeEntry>;
109
+ deleteTimeEntry(id: string): Promise<boolean>;
95
110
  }
96
111
 
97
112
  export interface DependencyRepository {
113
+ /** Dependencies where `taskId` is either the dependent or the upstream task. */
98
114
  getDependencies(taskId: string): Promise<TaskDependency[]>;
115
+ getDependency(id: string): Promise<TaskDependency | null>;
99
116
  addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency>;
117
+ removeDependency(id: string): Promise<boolean>;
100
118
  }
101
119
 
102
120
  export interface WebhookRepository {
@@ -230,6 +248,11 @@ export class InMemoryStore implements StorageAdapter {
230
248
  return all;
231
249
  }
232
250
 
251
+ async queryTasks(query: TaskQuery): Promise<Page<Task>> {
252
+ const tasks = Array.from(this.tasks.values()).filter((t) => matchesTaskQuery(t, query));
253
+ return paginate(tasks, (t) => t.createdAt, 'asc', query.limit, query.cursor);
254
+ }
255
+
233
256
  async getTask(id: string): Promise<Task | null> {
234
257
  return this.tasks.get(id) || null;
235
258
  }
@@ -503,6 +526,11 @@ export class InMemoryStore implements StorageAdapter {
503
526
  });
504
527
  }
505
528
 
529
+ async queryActivities(query: ActivityQuery): Promise<Page<Activity>> {
530
+ const activities = this.activities.filter((a) => matchesActivityQuery(a, query));
531
+ return paginate(activities, (a) => a.createdAt, 'desc', query.limit, query.cursor);
532
+ }
533
+
506
534
  async logActivity(activity: Omit<Activity, 'id' | 'createdAt'>): Promise<Activity> {
507
535
  const id = `act_${Math.random().toString(36).substring(2, 9)}`;
508
536
  const newAct: Activity = { ...activity, id, createdAt: new Date().toISOString() };
@@ -522,6 +550,10 @@ export class InMemoryStore implements StorageAdapter {
522
550
  return newEntry;
523
551
  }
524
552
 
553
+ async deleteTimeEntry(id: string): Promise<boolean> {
554
+ return this.timeEntries.delete(id);
555
+ }
556
+
525
557
  // Dependencies
526
558
  async getDependencies(taskId: string): Promise<TaskDependency[]> {
527
559
  return Array.from(this.dependencies.values()).filter(
@@ -529,6 +561,10 @@ export class InMemoryStore implements StorageAdapter {
529
561
  );
530
562
  }
531
563
 
564
+ async getDependency(id: string): Promise<TaskDependency | null> {
565
+ return this.dependencies.get(id) ?? null;
566
+ }
567
+
532
568
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
533
569
  const id = `dep_${Math.random().toString(36).substring(2, 9)}`;
534
570
  const newDep: TaskDependency = { ...dep, id };
@@ -536,6 +572,10 @@ export class InMemoryStore implements StorageAdapter {
536
572
  return newDep;
537
573
  }
538
574
 
575
+ async removeDependency(id: string): Promise<boolean> {
576
+ return this.dependencies.delete(id);
577
+ }
578
+
539
579
  // Webhooks
540
580
  async getWebhooks(): Promise<Webhook[]> {
541
581
  return Array.from(this.webhooks.values());
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ CriticalPathEngine,
4
+ InMemoryStore,
5
+ SQLiteStore,
6
+ FirebaseStore,
7
+ InMemoryFirestoreMock,
8
+ ValidationError,
9
+ createRolePolicy,
10
+ MAX_PAGE_SIZE,
11
+ type StorageAdapter,
12
+ type Task
13
+ } from '../index.js';
14
+
15
+ const stores: Array<[string, () => StorageAdapter]> = [
16
+ ['InMemoryStore', () => new InMemoryStore()],
17
+ ['SQLiteStore', () => new SQLiteStore({ filename: ':memory:' })],
18
+ ['FirebaseStore', () => new FirebaseStore({ db: new InMemoryFirestoreMock() })]
19
+ ];
20
+
21
+ describe.each(stores)('task and activity queries (%s)', (_name, makeStore) => {
22
+ async function setup() {
23
+ const engine = new CriticalPathEngine({ store: makeStore() });
24
+ const project = await engine.createProject({ name: 'Query' });
25
+ const other = await engine.createProject({ name: 'Other' });
26
+ const sprint = await engine.createIteration({ projectId: project.id, name: 'S1', status: 'active' });
27
+ const parent = await engine.createTask({ projectId: project.id, title: 'Parent', status: 'in_progress', priority: 'high', assigneeId: 'ana' });
28
+ const child = await engine.createTask({ projectId: project.id, title: 'Child', parentId: parent.id, status: 'todo', priority: 'low', iterationId: sprint.id });
29
+ const shared = await engine.createTask({
30
+ projectId: project.id,
31
+ title: 'Shared',
32
+ status: 'done',
33
+ priority: 'high',
34
+ assignees: [{ id: 'ana', type: 'user' }, { id: 'ben', type: 'user' }]
35
+ });
36
+ const elsewhere = await engine.createTask({ projectId: other.id, title: 'Elsewhere', assigneeId: 'ana' });
37
+ return { engine, project, other, sprint, parent, child, shared, elsewhere };
38
+ }
39
+ const titles = (tasks: Task[]) => tasks.map((t) => t.title).sort();
40
+
41
+ it('filters by status, priority, assignee (including assignees), iteration and parent', async () => {
42
+ const { engine, project, sprint, parent } = await setup();
43
+ const q = (query: object) => engine.queryTasks({ projectId: project.id, ...query }).then((p) => titles(p.items));
44
+
45
+ expect(await q({ status: ['todo', 'done'] })).toEqual(['Child', 'Shared']);
46
+ expect(await q({ priority: ['high'] })).toEqual(['Parent', 'Shared']);
47
+ expect(await q({ assigneeId: 'ana' })).toEqual(['Parent', 'Shared']);
48
+ expect(await q({ assigneeId: 'ben' })).toEqual(['Shared']);
49
+ expect(await q({ iterationId: sprint.id })).toEqual(['Child']);
50
+ expect(await q({ parentId: parent.id })).toEqual(['Child']);
51
+ expect(await q({ parentId: null })).toEqual(['Parent', 'Shared']);
52
+ expect(titles((await engine.queryTasks({ assigneeId: 'ana' })).items)).toEqual(['Elsewhere', 'Parent', 'Shared']);
53
+ });
54
+
55
+ it('pages through every task exactly once, oldest first', async () => {
56
+ const { engine, project } = await setup();
57
+ for (let i = 0; i < 12; i++) await engine.createTask({ projectId: project.id, title: `Bulk ${i}` });
58
+
59
+ const seen: Task[] = [];
60
+ let cursor: string | undefined;
61
+ do {
62
+ const page = await engine.queryTasks({ projectId: project.id, limit: 5, cursor });
63
+ expect(page.items.length).toBeLessThanOrEqual(5);
64
+ seen.push(...page.items);
65
+ cursor = page.nextCursor;
66
+ } while (cursor);
67
+
68
+ expect(seen).toHaveLength(15);
69
+ expect(new Set(seen.map((t) => t.id)).size).toBe(15);
70
+ const keys = seen.map((t) => `${t.createdAt}|${t.id}`);
71
+ expect([...keys].sort()).toEqual(keys);
72
+ });
73
+
74
+ it('pages the activity feed newest first', async () => {
75
+ const { engine, project } = await setup();
76
+ const all = (await engine.queryActivities({ projectId: project.id, limit: MAX_PAGE_SIZE })).items;
77
+ expect(all.length).toBeGreaterThan(3);
78
+ const keys = all.map((a) => `${a.createdAt}|${a.id}`);
79
+ expect([...keys].sort().reverse()).toEqual(keys);
80
+
81
+ const first = await engine.queryActivities({ projectId: project.id, limit: 2 });
82
+ const second = await engine.queryActivities({ projectId: project.id, limit: 2, cursor: first.nextCursor });
83
+ expect([...first.items, ...second.items].map((a) => a.id)).toEqual(all.slice(0, 4).map((a) => a.id));
84
+ });
85
+
86
+ it('rejects malformed cursors and clamps page sizes', async () => {
87
+ const { engine, project } = await setup();
88
+ await expect(engine.queryTasks({ projectId: project.id, cursor: 'not-a-cursor' })).rejects.toThrow(ValidationError);
89
+ expect((await engine.queryTasks({ projectId: project.id, limit: 0 })).items).toHaveLength(1);
90
+ expect((await engine.queryTasks({ projectId: project.id, limit: 10_000 })).items).toHaveLength(3);
91
+ });
92
+ });
93
+
94
+ describe('query authorization', () => {
95
+ it('limits cross-project queries and activity feeds to readable projects', async () => {
96
+ const engine = new CriticalPathEngine({ authorize: createRolePolicy() });
97
+ const mine = await engine.createProject({ name: 'Mine', members: [{ userId: 'ana', role: 'viewer' }] });
98
+ const secret = await engine.createProject({ name: 'Secret' });
99
+ await engine.createTask({ projectId: mine.id, title: 'Visible' });
100
+ await engine.createTask({ projectId: secret.id, title: 'Hidden' });
101
+ const ana = engine.withActor({ userId: 'ana' });
102
+
103
+ expect((await ana.queryTasks()).items.map((t) => t.title)).toEqual(['Visible']);
104
+ expect((await ana.queryTasks({ projectId: secret.id })).items).toEqual([]);
105
+ expect((await ana.queryTasks({ projectIds: [mine.id, secret.id] })).items.map((t) => t.title)).toEqual(['Visible']);
106
+ const feed = (await ana.queryActivities()).items;
107
+ expect(feed.length).toBeGreaterThan(0);
108
+ expect(feed.every((a) => a.projectId === mine.id)).toBe(true);
109
+ });
110
+ });
@@ -0,0 +1,113 @@
1
+ import type { Activity, Task } from '../types/index.js';
2
+ import { ValidationError } from '../domain/errors.js';
3
+
4
+ export const DEFAULT_PAGE_SIZE = 100;
5
+ export const MAX_PAGE_SIZE = 500;
6
+
7
+ /** Filters for `queryTasks`. All given filters must match. */
8
+ export interface TaskQuery {
9
+ projectId?: string;
10
+ /** Restrict to these projects (used for authorization when no `projectId` is given). */
11
+ projectIds?: string[];
12
+ /** Any of these statuses. */
13
+ status?: string[];
14
+ /** Matches `assigneeId` or any entry in `assignees`. */
15
+ assigneeId?: string;
16
+ /** Any of these priorities. */
17
+ priority?: string[];
18
+ iterationId?: string;
19
+ deliverableId?: string;
20
+ containerId?: string;
21
+ /** A task id for its subtasks, or `null` for top-level tasks only. */
22
+ parentId?: string | null;
23
+ limit?: number;
24
+ /** Opaque cursor from a previous page's `nextCursor`. */
25
+ cursor?: string;
26
+ }
27
+
28
+ export interface ActivityQuery {
29
+ projectId?: string;
30
+ projectIds?: string[];
31
+ taskId?: string;
32
+ limit?: number;
33
+ cursor?: string;
34
+ }
35
+
36
+ /** One page of results. `nextCursor` is set when more results exist. */
37
+ export interface Page<T> {
38
+ items: T[];
39
+ nextCursor?: string;
40
+ }
41
+
42
+ /** Clamps a requested page size to `[1, MAX_PAGE_SIZE]`, defaulting to `DEFAULT_PAGE_SIZE`. */
43
+ export function pageSize(limit: number | undefined): number {
44
+ if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_PAGE_SIZE;
45
+ return Math.min(Math.max(1, Math.floor(limit)), MAX_PAGE_SIZE);
46
+ }
47
+
48
+ /** Cursors encode the last item's sort key and id (keyset pagination). */
49
+ export function encodeCursor(sortKey: string, id: string): string {
50
+ return btoa(JSON.stringify([sortKey, id])).replace(/=+$/, '');
51
+ }
52
+
53
+ export function decodeCursor(cursor: string | undefined): [string, string] | undefined {
54
+ if (!cursor) return undefined;
55
+ try {
56
+ const value = JSON.parse(atob(cursor));
57
+ if (Array.isArray(value) && value.length === 2 && value.every((v) => typeof v === 'string')) {
58
+ return value as [string, string];
59
+ }
60
+ } catch {
61
+ // fall through
62
+ }
63
+ throw new ValidationError('Invalid pagination cursor.');
64
+ }
65
+
66
+ export function matchesTaskQuery(task: Task, query: TaskQuery): boolean {
67
+ if (query.projectId && task.projectId !== query.projectId) return false;
68
+ if (query.projectIds && !query.projectIds.includes(task.projectId)) return false;
69
+ if (query.status?.length && !query.status.includes(task.status)) return false;
70
+ if (query.priority?.length && !query.priority.includes(task.priority)) return false;
71
+ if (query.iterationId && task.iterationId !== query.iterationId) return false;
72
+ if (query.deliverableId && task.deliverableId !== query.deliverableId) return false;
73
+ if (query.containerId && task.containerId !== query.containerId) return false;
74
+ if (query.parentId === null && task.parentId) return false;
75
+ if (query.parentId && task.parentId !== query.parentId) return false;
76
+ if (query.assigneeId && task.assigneeId !== query.assigneeId && !task.assignees?.some((a) => a.id === query.assigneeId)) {
77
+ return false;
78
+ }
79
+ return true;
80
+ }
81
+
82
+ export function matchesActivityQuery(activity: Activity, query: ActivityQuery): boolean {
83
+ if (query.projectId && activity.projectId !== query.projectId) return false;
84
+ if (query.projectIds && (!activity.projectId || !query.projectIds.includes(activity.projectId))) return false;
85
+ if (query.taskId && activity.taskId !== query.taskId) return false;
86
+ return true;
87
+ }
88
+
89
+ /**
90
+ * Sorts by `(sortKey, id)` and returns the page after `cursor`. Tasks page oldest first
91
+ * (`asc`), activity feeds newest first (`desc`).
92
+ */
93
+ export function paginate<T extends { id: string }>(
94
+ items: T[],
95
+ sortKey: (item: T) => string,
96
+ direction: 'asc' | 'desc',
97
+ limit: number | undefined,
98
+ cursor: string | undefined
99
+ ): Page<T> {
100
+ const sign = direction === 'asc' ? 1 : -1;
101
+ const compare = (a: [string, string], b: [string, string]) =>
102
+ sign * (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0);
103
+ const after = decodeCursor(cursor);
104
+ const sorted = [...items].sort((a, b) => compare([sortKey(a), a.id], [sortKey(b), b.id]));
105
+ const remaining = after ? sorted.filter((item) => compare([sortKey(item), item.id], after) > 0) : sorted;
106
+ const size = pageSize(limit);
107
+ const page = remaining.slice(0, size);
108
+ const last = page[page.length - 1];
109
+ return {
110
+ items: page,
111
+ nextCursor: remaining.length > size && last ? encodeCursor(sortKey(last), last.id) : undefined
112
+ };
113
+ }