@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.
- package/CHANGELOG.md +43 -0
- package/README.md +14 -0
- package/dist/domain/events.d.ts +8 -2
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js +1 -0
- package/dist/domain/events.js.map +1 -1
- package/dist/engine/cascade.test.d.ts +2 -0
- package/dist/engine/cascade.test.d.ts.map +1 -0
- package/dist/engine/cascade.test.js +99 -0
- package/dist/engine/cascade.test.js.map +1 -0
- package/dist/engine/index.d.ts +25 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +117 -6
- package/dist/engine/index.js.map +1 -1
- package/dist/schemas/index.d.ts +81 -28
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +26 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/store/firebase.d.ts +12 -0
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +40 -8
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/index.d.ts +15 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +19 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/query.d.ts +50 -0
- package/dist/store/query.d.ts.map +1 -0
- package/dist/store/query.js +79 -0
- package/dist/store/query.js.map +1 -0
- package/dist/store/query.test.d.ts +2 -0
- package/dist/store/query.test.d.ts.map +1 -0
- package/dist/store/query.test.js +89 -0
- package/dist/store/query.test.js.map +1 -0
- package/dist/store/sqlite.d.ts +6 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +131 -24
- package/dist/store/sqlite.js.map +1 -1
- package/dist/webhooks/webhooks.test.js +2 -1
- package/dist/webhooks/webhooks.test.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/events.ts +7 -0
- package/src/engine/cascade.test.ts +121 -0
- package/src/engine/index.ts +126 -6
- package/src/schemas/index.ts +31 -0
- package/src/store/firebase.ts +47 -8
- package/src/store/index.ts +40 -0
- package/src/store/query.test.ts +110 -0
- package/src/store/query.ts +113 -0
- package/src/store/sqlite.ts +149 -31
- package/src/webhooks/webhooks.test.ts +2 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/schemas/index.ts
CHANGED
|
@@ -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
|
|
package/src/store/firebase.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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();
|
package/src/store/index.ts
CHANGED
|
@@ -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
|
+
}
|