@critical-path/core 0.21.0 → 0.23.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 (55) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +14 -0
  3. package/dist/domain/ddd.test.js +12 -0
  4. package/dist/domain/ddd.test.js.map +1 -1
  5. package/dist/domain/entities.d.ts.map +1 -1
  6. package/dist/domain/entities.js +3 -2
  7. package/dist/domain/entities.js.map +1 -1
  8. package/dist/domain/errors.d.ts +13 -0
  9. package/dist/domain/errors.d.ts.map +1 -0
  10. package/dist/domain/errors.js +19 -0
  11. package/dist/domain/errors.js.map +1 -0
  12. package/dist/domain/events.d.ts +9 -1
  13. package/dist/domain/events.d.ts.map +1 -1
  14. package/dist/domain/events.js.map +1 -1
  15. package/dist/domain/index.d.ts +1 -0
  16. package/dist/domain/index.d.ts.map +1 -1
  17. package/dist/domain/index.js +1 -0
  18. package/dist/domain/index.js.map +1 -1
  19. package/dist/engine/actor.test.d.ts +2 -0
  20. package/dist/engine/actor.test.d.ts.map +1 -0
  21. package/dist/engine/actor.test.js +53 -0
  22. package/dist/engine/actor.test.js.map +1 -0
  23. package/dist/engine/index.d.ts +23 -1
  24. package/dist/engine/index.d.ts.map +1 -1
  25. package/dist/engine/index.js +125 -29
  26. package/dist/engine/index.js.map +1 -1
  27. package/dist/index.test.js +20 -0
  28. package/dist/index.test.js.map +1 -1
  29. package/dist/store/sqlite.d.ts.map +1 -1
  30. package/dist/store/sqlite.js +5 -1
  31. package/dist/store/sqlite.js.map +1 -1
  32. package/dist/store/sqlite.test.js +20 -0
  33. package/dist/store/sqlite.test.js.map +1 -1
  34. package/dist/types/index.d.ts +7 -1
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/dist/utils/fractional-index.d.ts.map +1 -1
  37. package/dist/utils/fractional-index.js +22 -26
  38. package/dist/utils/fractional-index.js.map +1 -1
  39. package/dist/utils/fractional-index.test.js +31 -0
  40. package/dist/utils/fractional-index.test.js.map +1 -1
  41. package/package.json +1 -5
  42. package/src/domain/ddd.test.ts +22 -0
  43. package/src/domain/entities.ts +3 -2
  44. package/src/domain/errors.ts +19 -0
  45. package/src/domain/events.ts +6 -0
  46. package/src/domain/index.ts +1 -0
  47. package/src/engine/actor.test.ts +62 -0
  48. package/src/engine/index.ts +134 -30
  49. package/src/index.test.ts +24 -0
  50. package/src/store/sqlite.test.ts +22 -0
  51. package/src/store/sqlite.ts +7 -1
  52. package/src/types/index.ts +8 -0
  53. package/src/utils/fractional-index.test.ts +37 -0
  54. package/src/utils/fractional-index.ts +20 -26
  55. package/tsconfig.tsbuildinfo +1 -1
@@ -29,6 +29,7 @@ import { validateTransition, WorkflowValidationError } from '../utils/workflow.j
29
29
  import { resolveStatusDefinition } from '../utils/status.js';
30
30
  import { validateCustomFieldValues } from './custom-fields.js';
31
31
  import { generateProjectKey } from '../utils/key.js';
32
+ import { ValidationError } from './errors.js';
32
33
 
33
34
  export abstract class BaseEntity {
34
35
  public readonly id: string;
@@ -294,8 +295,8 @@ export class TaskEntity extends BaseEntity {
294
295
  public logTime(
295
296
  entry: { hours: number; isBillable?: boolean; userId?: string; description?: string }
296
297
  ): TimeEntry {
297
- if (entry.hours <= 0) {
298
- throw new Error('Logged hours must be a positive number.');
298
+ if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
299
+ throw new ValidationError('Logged hours must be a positive number.');
299
300
  }
300
301
 
301
302
  this.loggedHours = (this.loggedHours || 0) + entry.hours;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Thrown when input fails a domain rule. Route handlers map it to HTTP 400.
3
+ */
4
+ export class ValidationError extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = 'ValidationError';
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Thrown when a referenced entity does not exist. Route handlers map it to HTTP 404.
13
+ */
14
+ export class NotFoundError extends Error {
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = 'NotFoundError';
18
+ }
19
+ }
@@ -134,6 +134,11 @@ export interface ProjectUpdatedEvent extends DomainEvent<{ project: Project; pre
134
134
  readonly aggregateType: 'Project';
135
135
  }
136
136
 
137
+ export interface ProjectDeletedEvent extends DomainEvent<{ projectId: string; name: string; deletedTaskIds: string[] }> {
138
+ readonly name: 'project.deleted';
139
+ readonly aggregateType: 'Project';
140
+ }
141
+
137
142
  export interface WorkflowCreatedEvent extends DomainEvent<{ workflow: Workflow }> {
138
143
  readonly name: 'workflow.created';
139
144
  readonly aggregateType: 'Workflow';
@@ -207,6 +212,7 @@ export type CriticalPathDomainEvent =
207
212
  | TaskDependencyAddedEvent
208
213
  | ProjectCreatedEvent
209
214
  | ProjectUpdatedEvent
215
+ | ProjectDeletedEvent
210
216
  | WorkflowCreatedEvent
211
217
  | WorkflowUpdatedEvent
212
218
  | WorkflowDeletedEvent
@@ -1,6 +1,7 @@
1
1
  export * from './events.js';
2
2
  export * from './custom-fields.js';
3
3
  export * from './graph.js';
4
+ export * from './errors.js';
4
5
  export * from './entities.js';
5
6
  export * from './cpm.js';
6
7
  export * from './ladder.js';
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { CriticalPathEngine } from '../index.js';
3
+
4
+ describe('CriticalPathEngine.withActor', () => {
5
+ it('attributes mutations to the scoped actor and ignores identity claimed in payloads', async () => {
6
+ const engine = new CriticalPathEngine();
7
+ const alice = engine.withActor({ userId: 'alice', username: 'Alice' });
8
+
9
+ const project = await alice.createProject({ key: 'ACT', name: 'Actors', ownerId: 'mallory' });
10
+ const task = await alice.createTask({ projectId: project.id, title: 'Scoped' });
11
+ expect(task.reporterId).toBe('alice');
12
+
13
+ await alice.updateTask(task.id, { title: 'Renamed', actorId: 'ceo', actor: { userId: 'ceo' } });
14
+ const comment = await alice.addComment({ taskId: task.id, content: 'hi', authorId: 'ceo', authorType: 'agent' });
15
+ const reacted = await alice.addCommentReaction(comment.id, { emoji: '👍', userId: 'ceo' });
16
+ const entry = await alice.logTime({ taskId: task.id, hours: 1, userId: 'ceo' } as any);
17
+ const attachment = await alice.createAttachment({
18
+ taskId: task.id,
19
+ uploaderId: 'ceo',
20
+ filename: 'spec.md',
21
+ mimeType: 'text/markdown',
22
+ sizeBytes: 1,
23
+ url: 'https://example.com/spec.md'
24
+ });
25
+
26
+ expect(comment.authorId).toBe('alice');
27
+ expect(comment.authorType).toBe('user');
28
+ expect(reacted?.reactions?.[0].userId).toBe('alice');
29
+ expect(entry.userId).toBe('alice');
30
+ expect(attachment.uploaderId).toBe('alice');
31
+
32
+ const activities = await engine.store.getActivities({ projectId: project.id });
33
+ expect(activities.length).toBeGreaterThan(0);
34
+ expect(activities.every((a) => a.actorId === 'alice')).toBe(true);
35
+ });
36
+
37
+ it('does not change the base engine, and concurrent views stay isolated', async () => {
38
+ const engine = new CriticalPathEngine();
39
+ const project = await engine.createProject({ key: 'ISO', name: 'Isolation' });
40
+
41
+ const [a, b] = await Promise.all([
42
+ engine.withActor({ userId: 'a' }).createTask({ projectId: project.id, title: 'A' }),
43
+ engine.withActor({ userId: 'b' }).createTask({ projectId: project.id, title: 'B' })
44
+ ]);
45
+ expect(a.reporterId).toBe('a');
46
+ expect(b.reporterId).toBe('b');
47
+ expect(engine.actor).toBeUndefined();
48
+
49
+ // Views share state with the base engine
50
+ expect(await engine.getTask(a.id)).not.toBeNull();
51
+ });
52
+
53
+ it('still lets explicit updateTask options override the scoped actor', async () => {
54
+ const engine = new CriticalPathEngine();
55
+ const project = await engine.createProject({ key: 'OPT', name: 'Options' });
56
+ const task = await engine.createTask({ projectId: project.id, title: 'T' });
57
+
58
+ await engine.withActor({ userId: 'alice' }).updateTask(task.id, { title: 'T2' }, { actorId: 'automation' });
59
+ const activities = await engine.store.getActivities({ taskId: task.id });
60
+ expect(activities.some((a) => a.actorId === 'automation')).toBe(true);
61
+ });
62
+ });
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ Actor,
2
3
  CriticalPathConfig,
3
4
  Project,
4
5
  Task,
@@ -56,6 +57,7 @@ import {
56
57
  TaskDeletedEvent,
57
58
  ProjectCreatedEvent,
58
59
  ProjectUpdatedEvent,
60
+ ProjectDeletedEvent,
59
61
  WorkflowCreatedEvent,
60
62
  WorkflowUpdatedEvent,
61
63
  WorkflowDeletedEvent,
@@ -80,6 +82,7 @@ import {
80
82
  import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
81
83
  import { validateCustomFieldValues } from '../domain/custom-fields.js';
82
84
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
85
+ import { ValidationError, NotFoundError } from '../domain/errors.js';
83
86
  import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
84
87
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
85
88
  import {
@@ -96,6 +99,8 @@ export class CriticalPathEngine {
96
99
  public readonly plugins: PluginRegistry;
97
100
  public readonly events: DomainEventBus;
98
101
  public readonly ready: Promise<void> = Promise.resolve();
102
+ /** The identity mutations are attributed to. Set only on views returned by `withActor`. */
103
+ public readonly actor?: Actor;
99
104
 
100
105
  constructor(config: CriticalPathConfig = {}) {
101
106
  this.config = config;
@@ -118,6 +123,23 @@ export class CriticalPathEngine {
118
123
  }
119
124
  }
120
125
 
126
+ /**
127
+ * Returns a view of this engine that attributes every mutation to `actor`: activity log
128
+ * entries, comment authors, reactions, time entries, attachment uploaders and task reporters.
129
+ * Identity fields supplied in payloads are ignored on the view, so callers cannot claim to be
130
+ * someone else. The view shares the store, plugins and event bus, and does not modify this engine,
131
+ * so it is safe to create one per request.
132
+ */
133
+ withActor(actor: Actor): CriticalPathEngine {
134
+ const scoped = Object.create(this) as CriticalPathEngine;
135
+ Object.defineProperty(scoped, 'actor', { value: Object.freeze({ ...actor }), enumerable: true });
136
+ return scoped;
137
+ }
138
+
139
+ private actorIdOr(fallback: string): string {
140
+ return this.actor?.userId ?? fallback;
141
+ }
142
+
121
143
  private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
122
144
  if (data.workflows) {
123
145
  for (const wf of data.workflows) {
@@ -180,7 +202,7 @@ export class CriticalPathEngine {
180
202
  await this.events.publish(event);
181
203
 
182
204
  await this.store.logActivity({
183
- actorId: 'system',
205
+ actorId: this.actorIdOr('system'),
184
206
  action: 'workflow.created',
185
207
  details: { name: created.name }
186
208
  });
@@ -206,7 +228,7 @@ export class CriticalPathEngine {
206
228
  await this.events.publish(event);
207
229
 
208
230
  await this.store.logActivity({
209
- actorId: 'system',
231
+ actorId: this.actorIdOr('system'),
210
232
  action: 'workflow.updated',
211
233
  details: { name: updated.name }
212
234
  });
@@ -233,7 +255,7 @@ export class CriticalPathEngine {
233
255
  await this.events.publish(event);
234
256
 
235
257
  await this.store.logActivity({
236
- actorId: 'system',
258
+ actorId: this.actorIdOr('system'),
237
259
  action: 'workflow.deleted',
238
260
  details: { name: existing.name }
239
261
  });
@@ -304,7 +326,7 @@ export class CriticalPathEngine {
304
326
 
305
327
  await this.store.logActivity({
306
328
  projectId: created.id,
307
- actorId: project.ownerId || 'system',
329
+ actorId: this.actorIdOr(project.ownerId || 'system'),
308
330
  action: 'project.created',
309
331
  details: { name: created.name, key: created.key }
310
332
  });
@@ -331,7 +353,7 @@ export class CriticalPathEngine {
331
353
 
332
354
  await this.store.logActivity({
333
355
  projectId: updated.id,
334
- actorId: 'system',
356
+ actorId: this.actorIdOr('system'),
335
357
  action: 'project.updated',
336
358
  details: { name: updated.name }
337
359
  });
@@ -340,6 +362,46 @@ export class CriticalPathEngine {
340
362
  return updated;
341
363
  }
342
364
 
365
+ /**
366
+ * Deletes a project and its tasks. Each task goes through `deleteTask`, so plugin
367
+ * hooks run and `task.deleted` events fire before `project.deleted` is published.
368
+ */
369
+ async deleteProject(id: string): Promise<boolean> {
370
+ const existing = await this.store.getProject(id);
371
+ if (!existing) return false;
372
+
373
+ const tasks = await this.store.getTasks(id);
374
+ const deletedTaskIds: string[] = [];
375
+ for (const task of tasks) {
376
+ if (await this.deleteTask(task.id)) {
377
+ deletedTaskIds.push(task.id);
378
+ }
379
+ }
380
+
381
+ const deleted = await this.store.deleteProject(id);
382
+ if (deleted) {
383
+ const now = new Date().toISOString();
384
+ const event: ProjectDeletedEvent = {
385
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
386
+ name: 'project.deleted',
387
+ aggregateId: id,
388
+ aggregateType: 'Project',
389
+ occurredAt: now,
390
+ payload: { projectId: id, name: existing.name, deletedTaskIds }
391
+ };
392
+ await this.events.publish(event);
393
+
394
+ await this.store.logActivity({
395
+ projectId: id,
396
+ actorId: this.actorIdOr('system'),
397
+ action: 'project.deleted',
398
+ details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
399
+ });
400
+ this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
401
+ }
402
+ return deleted;
403
+ }
404
+
343
405
  // --- Tasks ---
344
406
  async getTasks(projectId?: string): Promise<Task[]> {
345
407
  return this.store.getTasks(projectId);
@@ -350,6 +412,9 @@ export class CriticalPathEngine {
350
412
  }
351
413
 
352
414
  async createTask(taskInput: CreateTaskInput): Promise<Task> {
415
+ if (this.actor && !taskInput.reporterId) {
416
+ taskInput = { ...taskInput, reporterId: this.actor.userId };
417
+ }
353
418
  const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
354
419
  const projectId = processedInput.projectId || taskInput.projectId;
355
420
  const project = await this.store.getProject(projectId);
@@ -435,7 +500,7 @@ export class CriticalPathEngine {
435
500
  await this.store.logActivity({
436
501
  projectId: created.projectId,
437
502
  taskId: created.id,
438
- actorId: created.reporterId || 'system',
503
+ actorId: this.actorIdOr(created.reporterId || 'system'),
439
504
  action: 'task.created',
440
505
  details: { title: created.title, status: created.status }
441
506
  });
@@ -462,25 +527,30 @@ export class CriticalPathEngine {
462
527
  const existing = await this.store.getTask(id);
463
528
  if (!existing) return null;
464
529
 
530
+ // On a withActor view, the scoped actor replaces any identity claimed in the payload.
531
+ // Explicit options from trusted server-side code still take precedence.
532
+ options = options ?? (this.actor ? { actor: this.actor } : undefined);
533
+ const claimed = this.actor ? undefined : updates;
534
+
465
535
  const actorId =
466
536
  options?.actorId ||
467
- updates.actorId ||
537
+ claimed?.actorId ||
468
538
  options?.actor?.userId ||
469
- updates.actor?.userId ||
539
+ claimed?.actor?.userId ||
470
540
  'system';
471
541
  const actorName =
472
542
  options?.actorName ||
473
- updates.actorName ||
543
+ claimed?.actorName ||
474
544
  options?.actor?.username ||
475
- updates.actor?.username ||
545
+ claimed?.actor?.username ||
476
546
  (actorId === 'system' ? 'System' : undefined);
477
547
  const actorType =
478
548
  options?.actorType ||
479
- updates.actorType ||
549
+ claimed?.actorType ||
480
550
  options?.actor?.actorType ||
481
- updates.actor?.actorType ||
551
+ claimed?.actor?.actorType ||
482
552
  (actorId === 'system' ? 'system' : 'user');
483
- const actorObj = options?.actor || updates.actor || {
553
+ const actorObj = options?.actor || claimed?.actor || {
484
554
  userId: actorId,
485
555
  username: actorName,
486
556
  actorType
@@ -861,7 +931,7 @@ export class CriticalPathEngine {
861
931
  await this.store.logActivity({
862
932
  projectId: downstream.projectId,
863
933
  taskId: downstream.id,
864
- actorId: 'system',
934
+ actorId: this.actorIdOr('system'),
865
935
  action: 'task.unblocked',
866
936
  details: { unblockedByTaskId: completedTask.id }
867
937
  });
@@ -905,7 +975,7 @@ export class CriticalPathEngine {
905
975
  await this.store.logActivity({
906
976
  projectId: existing.projectId,
907
977
  taskId: id,
908
- actorId: 'system',
978
+ actorId: this.actorIdOr('system'),
909
979
  action: 'task.deleted',
910
980
  details: { title: existing.title }
911
981
  });
@@ -916,14 +986,9 @@ export class CriticalPathEngine {
916
986
 
917
987
  // --- Task Dependencies & Graph ---
918
988
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
919
- const existingDependencies = await this.store.getDependencies(dep.taskId);
920
- const allProjectDeps = [
921
- ...existingDependencies,
922
- ...(await this.store.getDependencies(dep.dependsOnTaskId))
923
- ];
924
-
925
989
  // Enforce Directed Acyclic Graph (DAG) Invariant
926
- const cycleCheck = detectDependencyCycle(allProjectDeps, dep);
990
+ const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
991
+ const cycleCheck = detectDependencyCycle(reachableDeps, dep);
927
992
  if (cycleCheck.hasCycle) {
928
993
  throw new CircularDependencyError(dep.taskId, dep.dependsOnTaskId, cycleCheck.cyclePath);
929
994
  }
@@ -944,6 +1009,37 @@ export class CriticalPathEngine {
944
1009
  return created;
945
1010
  }
946
1011
 
1012
+ /**
1013
+ * Collects every dependency edge reachable upstream from `startTaskId`. A new edge
1014
+ * `taskId -> dependsOnTaskId` creates a cycle exactly when `taskId` is reachable from
1015
+ * `dependsOnTaskId`, so this is the full set of edges the cycle check needs.
1016
+ */
1017
+ private async collectUpstreamDependencies(startTaskId: string): Promise<TaskDependency[]> {
1018
+ const edges = new Map<string, TaskDependency>();
1019
+ const visited = new Set<string>();
1020
+ let frontier = [startTaskId];
1021
+
1022
+ while (frontier.length > 0) {
1023
+ const next: string[] = [];
1024
+ const results = await Promise.all(frontier.map((id) => this.store.getDependencies(id)));
1025
+ frontier.forEach((id) => visited.add(id));
1026
+
1027
+ for (const deps of results) {
1028
+ for (const d of deps) {
1029
+ // getDependencies returns edges in both directions; follow only outgoing ones.
1030
+ if (!visited.has(d.taskId)) continue;
1031
+ edges.set(d.id, d);
1032
+ if (!visited.has(d.dependsOnTaskId) && !next.includes(d.dependsOnTaskId)) {
1033
+ next.push(d.dependsOnTaskId);
1034
+ }
1035
+ }
1036
+ }
1037
+ frontier = next;
1038
+ }
1039
+
1040
+ return Array.from(edges.values());
1041
+ }
1042
+
947
1043
  async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
948
1044
  const dependencies = await this.store.getDependencies(taskId);
949
1045
  const upstreamTaskIds = new Set<string>();
@@ -996,8 +1092,8 @@ export class CriticalPathEngine {
996
1092
 
997
1093
  // --- Time Tracking ---
998
1094
  async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt' | 'userId'> & { userId?: string; loggedAt?: string }): Promise<TimeEntry> {
999
- if (entry.hours <= 0) {
1000
- throw new Error('Logged hours must be a positive number.');
1095
+ if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
1096
+ throw new ValidationError('Logged hours must be a positive number.');
1001
1097
  }
1002
1098
 
1003
1099
  const task = await this.store.getTask(entry.taskId);
@@ -1014,7 +1110,7 @@ export class CriticalPathEngine {
1014
1110
 
1015
1111
  const fullEntry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string } = {
1016
1112
  ...entry,
1017
- userId: entry.userId || task?.assigneeId || 'system',
1113
+ userId: this.actor?.userId || entry.userId || task?.assigneeId || 'system',
1018
1114
  loggedAt: entry.loggedAt
1019
1115
  };
1020
1116
 
@@ -1044,6 +1140,9 @@ export class CriticalPathEngine {
1044
1140
  }
1045
1141
 
1046
1142
  async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
1143
+ if (this.actor) {
1144
+ comment = { ...comment, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' };
1145
+ }
1047
1146
  const mentions = comment.mentions ?? extractMentions(comment.content);
1048
1147
  const created = await this.store.addComment({ ...comment, mentions });
1049
1148
  const now = new Date().toISOString();
@@ -1110,6 +1209,7 @@ export class CriticalPathEngine {
1110
1209
  }
1111
1210
 
1112
1211
  async addCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
1212
+ if (this.actor) reaction = { ...reaction, userId: this.actor.userId };
1113
1213
  const existing = await this.store.getComment(commentId);
1114
1214
  if (!existing) return null;
1115
1215
 
@@ -1155,6 +1255,7 @@ export class CriticalPathEngine {
1155
1255
  }
1156
1256
 
1157
1257
  async removeCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
1258
+ if (this.actor) reaction = { ...reaction, userId: this.actor.userId };
1158
1259
  const existing = await this.store.getComment(commentId);
1159
1260
  if (!existing) return null;
1160
1261
 
@@ -1199,6 +1300,9 @@ export class CriticalPathEngine {
1199
1300
  }
1200
1301
 
1201
1302
  async createAttachment(attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Attachment> {
1303
+ if (this.actor) {
1304
+ attachment = { ...attachment, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' };
1305
+ }
1202
1306
  validateAttachmentUrl(attachment.url);
1203
1307
  const created = await this.store.createAttachment(attachment);
1204
1308
  const now = new Date().toISOString();
@@ -1288,7 +1392,7 @@ export class CriticalPathEngine {
1288
1392
  async readAttachmentText(id: string): Promise<string> {
1289
1393
  const attachment = await this.getAttachment(id);
1290
1394
  if (!attachment) {
1291
- throw new Error(`Attachment with id "${id}" not found.`);
1395
+ throw new NotFoundError(`Attachment with id "${id}" not found.`);
1292
1396
  }
1293
1397
 
1294
1398
  if (attachment.storageKey && this.fileStorage && typeof this.fileStorage.download === 'function') {
@@ -1430,7 +1534,7 @@ export class CriticalPathEngine {
1430
1534
 
1431
1535
  await this.store.logActivity({
1432
1536
  projectId: created.projectId,
1433
- actorId: 'system',
1537
+ actorId: this.actorIdOr('system'),
1434
1538
  action: 'deliverable.created',
1435
1539
  details: { title: created.title, format: created.format }
1436
1540
  });
@@ -1483,7 +1587,7 @@ export class CriticalPathEngine {
1483
1587
 
1484
1588
  await this.store.logActivity({
1485
1589
  projectId: updated.projectId,
1486
- actorId: 'system',
1590
+ actorId: this.actorIdOr('system'),
1487
1591
  action: 'deliverable.updated',
1488
1592
  details: { title: updated.title, status: updated.status }
1489
1593
  });
@@ -1511,7 +1615,7 @@ export class CriticalPathEngine {
1511
1615
 
1512
1616
  await this.store.logActivity({
1513
1617
  projectId: existing.projectId,
1514
- actorId: 'system',
1618
+ actorId: this.actorIdOr('system'),
1515
1619
  action: 'deliverable.deleted',
1516
1620
  details: { title: existing.title }
1517
1621
  });
@@ -1661,7 +1765,7 @@ export class CriticalPathEngine {
1661
1765
  ): Promise<TimelineLadder> {
1662
1766
  const project = await this.store.getProject(projectId);
1663
1767
  if (!project) {
1664
- throw new Error(`Project with ID "${projectId}" not found.`);
1768
+ throw new NotFoundError(`Project with ID "${projectId}" not found.`);
1665
1769
  }
1666
1770
 
1667
1771
  let tasks = await this.store.getTasks(projectId);
package/src/index.test.ts CHANGED
@@ -3,6 +3,30 @@ import { CriticalPathEngine, deriveTaskLifecycleState, DEFAULT_VFX_WORKFLOW } fr
3
3
  import type { CriticalPathPlugin } from './types/index.js';
4
4
 
5
5
  describe('CriticalPathEngine Core Tests', () => {
6
+ it('deletes a project with its tasks and publishes deletion events', async () => {
7
+ const engine = new CriticalPathEngine();
8
+ const proj = await engine.createProject({ key: 'DEL', name: 'Doomed Project' });
9
+ const keep = await engine.createProject({ key: 'KEEP', name: 'Survivor' });
10
+ const t1 = await engine.createTask({ projectId: proj.id, title: 'One' });
11
+ const t2 = await engine.createTask({ projectId: proj.id, title: 'Two' });
12
+ const other = await engine.createTask({ projectId: keep.id, title: 'Other' });
13
+
14
+ const published: string[] = [];
15
+ engine.events.subscribe('*', (event) => {
16
+ published.push(event.name);
17
+ });
18
+
19
+ expect(await engine.deleteProject(proj.id)).toBe(true);
20
+ expect(await engine.getProject(proj.id)).toBeNull();
21
+ expect(await engine.getTask(t1.id)).toBeNull();
22
+ expect(await engine.getTask(t2.id)).toBeNull();
23
+ expect(await engine.getTask(other.id)).not.toBeNull();
24
+ expect(published.filter((n) => n === 'task.deleted')).toHaveLength(2);
25
+ expect(published.at(-1)).toBe('project.deleted');
26
+
27
+ expect(await engine.deleteProject(proj.id)).toBe(false);
28
+ });
29
+
6
30
  it('creates projects and tasks with activity logs', async () => {
7
31
  const engine = new CriticalPathEngine();
8
32
 
@@ -1,4 +1,7 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
2
5
  import { SQLiteStore } from './sqlite.js';
3
6
 
4
7
  describe('SQLiteStore', () => {
@@ -194,3 +197,22 @@ describe('SQLiteStore', () => {
194
197
  expect(remaining).toHaveLength(0);
195
198
  });
196
199
  });
200
+
201
+ describe('SQLiteStore outside the vitest runtime', () => {
202
+ const distEntry = fileURLToPath(new URL('../../dist/index.js', import.meta.url));
203
+
204
+ // vitest injects `require` into ESM modules, which once hid an `eval("require(...)")` failure
205
+ // that broke `new SQLiteStore({ filename })` in plain Node. Run the built package in a real Node process.
206
+ it.skipIf(!existsSync(distEntry))('constructs from a filename in a plain Node ESM process', () => {
207
+ const script = `
208
+ const { SQLiteStore } = await import(${JSON.stringify(pathToFileURL(distEntry).href)});
209
+ const store = new SQLiteStore({ filename: ':memory:' });
210
+ const project = await store.createProject({ key: 'SMOKE', name: 'Smoke' });
211
+ process.stdout.write(project.key);
212
+ `;
213
+ const output = execFileSync(process.execPath, ['--input-type=module', '--no-warnings', '-e', script], {
214
+ encoding: 'utf8'
215
+ });
216
+ expect(output).toBe('SMOKE');
217
+ });
218
+ });
@@ -41,7 +41,13 @@ export class SQLiteStore implements StorageAdapter {
41
41
  throw new Error('SQLiteStore is only available in Node.js environments.');
42
42
  }
43
43
  try {
44
- const nodeSqlite = eval("require('node:sqlite')");
44
+ // `require` is undefined in ESM, and a static import would break browser bundles of core.
45
+ const nodeSqlite = (globalThis as any).process?.getBuiltinModule?.('node:sqlite') as
46
+ | typeof import('node:sqlite')
47
+ | undefined;
48
+ if (!nodeSqlite) {
49
+ throw new Error('process.getBuiltinModule is unavailable (requires Node.js 22.3+)');
50
+ }
45
51
  this.db = new nodeSqlite.DatabaseSync(config.filename || ':memory:');
46
52
  } catch (err: any) {
47
53
  throw new Error(`Failed to load node:sqlite module: ${err.message}`);
@@ -300,6 +300,13 @@ export interface Iteration {
300
300
 
301
301
  export type AuthorType = 'user' | 'agent' | 'system';
302
302
 
303
+ /** The identity a mutation is attributed to (see `CriticalPathEngine.withActor`). */
304
+ export interface Actor {
305
+ userId: string;
306
+ username?: string;
307
+ actorType?: AuthorType;
308
+ }
309
+
303
310
  export interface CommentReaction {
304
311
  emoji: string;
305
312
  userId: string;
@@ -409,6 +416,7 @@ export interface Webhook {
409
416
  export type WebhookEvent =
410
417
  | 'project.created'
411
418
  | 'project.updated'
419
+ | 'project.deleted'
412
420
  | 'task.created'
413
421
  | 'task.updated'
414
422
  | 'task.deleted'
@@ -120,6 +120,43 @@ describe('fractional-index', () => {
120
120
  current = next;
121
121
  }
122
122
  });
123
+
124
+ it('handles repeated insertions directly after the same lower bound', () => {
125
+ const lower = 'a0';
126
+ let upper = 'a1';
127
+
128
+ // Repeatedly drop a card just below the first card
129
+ for (let i = 0; i < 50; i++) {
130
+ const next = generateKeyBetween(lower, upper);
131
+ expect(lower < next).toBe(true);
132
+ expect(next < upper).toBe(true);
133
+ upper = next;
134
+ }
135
+ });
136
+
137
+ it('keeps keys strictly ordered under random insertions', () => {
138
+ const keys = ['a0', 'a1'];
139
+ let seed = 42;
140
+ const random = () => {
141
+ seed = (seed * 1103515245 + 12345) % 2147483648;
142
+ return seed / 2147483648;
143
+ };
144
+
145
+ for (let i = 0; i < 500; i++) {
146
+ const idx = Math.floor(random() * (keys.length + 1));
147
+ const next = generateKeyBetween(keys[idx - 1] ?? null, keys[idx] ?? null);
148
+ keys.splice(idx, 0, next);
149
+ }
150
+
151
+ for (let i = 1; i < keys.length; i++) {
152
+ expect(keys[i - 1] < keys[i]).toBe(true);
153
+ }
154
+ });
155
+
156
+ it('throws when the lower bound does not sort before the upper bound', () => {
157
+ expect(() => generateKeyBetween('a1', 'a0')).toThrow(RangeError);
158
+ expect(() => generateKeyBetween('a0', 'a0')).toThrow(RangeError);
159
+ });
123
160
  });
124
161
 
125
162
  describe('generateNKeysBetween', () => {