@critical-path/core 0.5.2 → 0.7.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 (90) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +57 -21
  3. package/dist/domain/custom-fields.d.ts +9 -0
  4. package/dist/domain/custom-fields.d.ts.map +1 -0
  5. package/dist/domain/custom-fields.js +72 -0
  6. package/dist/domain/custom-fields.js.map +1 -0
  7. package/dist/domain/ddd.test.d.ts +2 -0
  8. package/dist/domain/ddd.test.d.ts.map +1 -0
  9. package/dist/domain/ddd.test.js +246 -0
  10. package/dist/domain/ddd.test.js.map +1 -0
  11. package/dist/domain/entities.d.ts +82 -0
  12. package/dist/domain/entities.d.ts.map +1 -0
  13. package/dist/domain/entities.js +321 -0
  14. package/dist/domain/entities.js.map +1 -0
  15. package/dist/domain/events.d.ts +153 -0
  16. package/dist/domain/events.d.ts.map +1 -0
  17. package/dist/domain/events.js +35 -0
  18. package/dist/domain/events.js.map +1 -0
  19. package/dist/domain/graph.d.ts +19 -0
  20. package/dist/domain/graph.d.ts.map +1 -0
  21. package/dist/domain/graph.js +58 -0
  22. package/dist/domain/graph.js.map +1 -0
  23. package/dist/domain/index.d.ts +5 -0
  24. package/dist/domain/index.d.ts.map +1 -0
  25. package/dist/domain/index.js +5 -0
  26. package/dist/domain/index.js.map +1 -0
  27. package/dist/engine/index.d.ts +32 -2
  28. package/dist/engine/index.d.ts.map +1 -1
  29. package/dist/engine/index.js +416 -6
  30. package/dist/engine/index.js.map +1 -1
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/storage/file-storage.d.ts +24 -0
  36. package/dist/storage/file-storage.d.ts.map +1 -0
  37. package/dist/storage/file-storage.js +73 -0
  38. package/dist/storage/file-storage.js.map +1 -0
  39. package/dist/storage/firebase-storage.d.ts +48 -0
  40. package/dist/storage/firebase-storage.d.ts.map +1 -0
  41. package/dist/storage/firebase-storage.js +142 -0
  42. package/dist/storage/firebase-storage.js.map +1 -0
  43. package/dist/storage/index.d.ts +4 -0
  44. package/dist/storage/index.d.ts.map +1 -0
  45. package/dist/storage/index.js +4 -0
  46. package/dist/storage/index.js.map +1 -0
  47. package/dist/storage/s3.d.ts +36 -0
  48. package/dist/storage/s3.d.ts.map +1 -0
  49. package/dist/storage/s3.js +135 -0
  50. package/dist/storage/s3.js.map +1 -0
  51. package/dist/storage/storage.test.d.ts +2 -0
  52. package/dist/storage/storage.test.d.ts.map +1 -0
  53. package/dist/storage/storage.test.js +117 -0
  54. package/dist/storage/storage.test.js.map +1 -0
  55. package/dist/store/firebase.d.ts +12 -1
  56. package/dist/store/firebase.d.ts.map +1 -1
  57. package/dist/store/firebase.js +63 -1
  58. package/dist/store/firebase.js.map +1 -1
  59. package/dist/store/index.d.ts +49 -2
  60. package/dist/store/index.d.ts.map +1 -1
  61. package/dist/store/index.js +49 -0
  62. package/dist/store/index.js.map +1 -1
  63. package/dist/store/sqlite.d.ts +12 -1
  64. package/dist/store/sqlite.d.ts.map +1 -1
  65. package/dist/store/sqlite.js +128 -6
  66. package/dist/store/sqlite.js.map +1 -1
  67. package/dist/store/sqlite.test.js +47 -1
  68. package/dist/store/sqlite.test.js.map +1 -1
  69. package/dist/types/index.d.ts +57 -1
  70. package/dist/types/index.d.ts.map +1 -1
  71. package/package.json +1 -1
  72. package/src/domain/custom-fields.ts +131 -0
  73. package/src/domain/ddd.test.ts +307 -0
  74. package/src/domain/entities.ts +390 -0
  75. package/src/domain/events.ts +196 -0
  76. package/src/domain/graph.ts +70 -0
  77. package/src/domain/index.ts +4 -0
  78. package/src/engine/index.ts +518 -8
  79. package/src/index.ts +2 -0
  80. package/src/storage/file-storage.ts +96 -0
  81. package/src/storage/firebase-storage.ts +177 -0
  82. package/src/storage/index.ts +3 -0
  83. package/src/storage/s3.ts +165 -0
  84. package/src/storage/storage.test.ts +146 -0
  85. package/src/store/firebase.ts +64 -1
  86. package/src/store/index.ts +99 -11
  87. package/src/store/sqlite.test.ts +59 -1
  88. package/src/store/sqlite.ts +172 -6
  89. package/src/types/index.ts +70 -0
  90. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,390 @@
1
+ import type {
2
+ Task,
3
+ Project,
4
+ Workflow,
5
+ TimeEntry,
6
+ Priority,
7
+ TaskStatus,
8
+ CustomFieldDefinition,
9
+ CreateTaskInput,
10
+ CreateProjectInput
11
+ } from '../types/index.js';
12
+ import type {
13
+ DomainEvent,
14
+ TaskCreatedEvent,
15
+ TaskStatusChangedEvent,
16
+ TaskUpdatedEvent,
17
+ TimeLoggedEvent,
18
+ ProjectCreatedEvent
19
+ } from './events.js';
20
+ import { validateTransition, WorkflowValidationError } from '../utils/workflow.js';
21
+ import { resolveStatusDefinition } from '../utils/status.js';
22
+ import { validateCustomFieldValues } from './custom-fields.js';
23
+ import { generateProjectKey } from '../utils/key.js';
24
+
25
+ export abstract class BaseEntity {
26
+ public readonly id: string;
27
+ public createdAt: string;
28
+ public updatedAt: string;
29
+ private uncommittedEvents: DomainEvent[] = [];
30
+
31
+ constructor(id: string, createdAt?: string, updatedAt?: string) {
32
+ const now = new Date().toISOString();
33
+ this.id = id;
34
+ this.createdAt = createdAt || now;
35
+ this.updatedAt = updatedAt || now;
36
+ }
37
+
38
+ protected raiseEvent(event: DomainEvent): void {
39
+ this.uncommittedEvents.push(event);
40
+ }
41
+
42
+ public getUncommittedEvents(): DomainEvent[] {
43
+ return [...this.uncommittedEvents];
44
+ }
45
+
46
+ public clearEvents(): void {
47
+ this.uncommittedEvents = [];
48
+ }
49
+
50
+ protected markUpdated(): void {
51
+ this.updatedAt = new Date().toISOString();
52
+ }
53
+ }
54
+
55
+ export class TaskEntity extends BaseEntity {
56
+ public projectId: string;
57
+ public key?: string;
58
+ public title: string;
59
+ public description?: string;
60
+ public status: TaskStatus;
61
+ public priority: Priority;
62
+ public taskType?: string;
63
+ public assigneeId?: string;
64
+ public reporterId?: string;
65
+ public reviewerId?: string;
66
+ public iterationId?: string;
67
+ public teamId?: string;
68
+ public containerId?: string;
69
+ public plannedStartDate?: string;
70
+ public actualStartDate?: string;
71
+ public actualEndDate?: string;
72
+ public dueDate?: string;
73
+ public estimatedHours?: number;
74
+ public loggedHours?: number;
75
+ public actualHours?: number;
76
+ public billableHours?: number;
77
+ public estimatedDurationMinutes?: number;
78
+ public actualDurationMinutes?: number;
79
+ public billableDurationMinutes?: number;
80
+ public progress?: number;
81
+ public tags?: string[];
82
+ public customFields?: Record<string, unknown>;
83
+ public parentId?: string;
84
+
85
+ constructor(data: Task) {
86
+ super(data.id, data.createdAt, data.updatedAt);
87
+ this.projectId = data.projectId;
88
+ this.key = data.key;
89
+ this.title = data.title;
90
+ this.description = data.description;
91
+ this.status = data.status;
92
+ this.priority = data.priority;
93
+ this.taskType = data.taskType;
94
+ this.assigneeId = data.assigneeId;
95
+ this.reporterId = data.reporterId;
96
+ this.reviewerId = data.reviewerId;
97
+ this.iterationId = data.iterationId;
98
+ this.teamId = data.teamId;
99
+ this.containerId = data.containerId;
100
+ this.plannedStartDate = data.plannedStartDate;
101
+ this.actualStartDate = data.actualStartDate;
102
+ this.actualEndDate = data.actualEndDate;
103
+ this.dueDate = data.dueDate;
104
+ this.estimatedHours = data.estimatedHours;
105
+ this.loggedHours = data.loggedHours ?? 0;
106
+ this.actualHours = data.actualHours;
107
+ this.billableHours = data.billableHours;
108
+ this.estimatedDurationMinutes = data.estimatedDurationMinutes;
109
+ this.actualDurationMinutes = data.actualDurationMinutes;
110
+ this.billableDurationMinutes = data.billableDurationMinutes;
111
+ this.progress = data.progress ?? 0;
112
+ this.tags = data.tags ? [...data.tags] : [];
113
+ this.customFields = data.customFields ? { ...data.customFields } : {};
114
+ this.parentId = data.parentId;
115
+ }
116
+
117
+ public static create(
118
+ input: CreateTaskInput,
119
+ options?: { id?: string; workflow?: Workflow; customFieldDefs?: CustomFieldDefinition[] }
120
+ ): TaskEntity {
121
+ const id = options?.id || `task_${Math.random().toString(36).substring(2, 9)}`;
122
+ const now = new Date().toISOString();
123
+
124
+ if (options?.customFieldDefs) {
125
+ validateCustomFieldValues(options.customFieldDefs, input.customFields);
126
+ }
127
+
128
+ const task = new TaskEntity({
129
+ ...input,
130
+ id,
131
+ createdAt: now,
132
+ updatedAt: now,
133
+ status: input.status || options?.workflow?.defaultStatusKey || 'todo',
134
+ priority: input.priority || 'medium',
135
+ loggedHours: input.loggedHours ?? 0,
136
+ progress: input.progress ?? 0,
137
+ tags: input.tags ?? [],
138
+ customFields: input.customFields ?? {}
139
+ });
140
+
141
+ const createdEvent: TaskCreatedEvent = {
142
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
143
+ name: 'task.created',
144
+ aggregateId: task.id,
145
+ aggregateType: 'Task',
146
+ occurredAt: now,
147
+ payload: { task: task.toPlain() }
148
+ };
149
+ task.raiseEvent(createdEvent);
150
+
151
+ return task;
152
+ }
153
+
154
+ public transitionTo(
155
+ newStatus: string,
156
+ workflow?: Workflow,
157
+ options?: { statusDefs?: Workflow['statuses']; actorId?: string }
158
+ ): this {
159
+ if (this.status === newStatus) return this;
160
+
161
+ const isValid = validateTransition(workflow, this.status, newStatus);
162
+ if (!isValid) {
163
+ throw new WorkflowValidationError(this.status, newStatus, workflow?.id);
164
+ }
165
+
166
+ const previousStatus = this.status;
167
+ this.status = newStatus;
168
+ const now = new Date().toISOString();
169
+
170
+ const statusDef = resolveStatusDefinition(newStatus, options?.statusDefs || workflow?.statuses);
171
+ if (statusDef.executionState === 'active' && !this.actualStartDate) {
172
+ this.actualStartDate = now;
173
+ }
174
+ if (statusDef.completionState === 'done') {
175
+ this.actualEndDate = now;
176
+ if (this.progress !== undefined && this.progress < 100) {
177
+ this.progress = 100;
178
+ }
179
+ }
180
+
181
+ this.markUpdated();
182
+
183
+ const event: TaskStatusChangedEvent = {
184
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
185
+ name: 'task.status_changed',
186
+ aggregateId: this.id,
187
+ aggregateType: 'Task',
188
+ occurredAt: now,
189
+ payload: {
190
+ task: this.toPlain(),
191
+ previousStatus,
192
+ newStatus
193
+ }
194
+ };
195
+ this.raiseEvent(event);
196
+
197
+ return this;
198
+ }
199
+
200
+ public logTime(
201
+ entry: { hours: number; isBillable?: boolean; userId?: string; description?: string }
202
+ ): TimeEntry {
203
+ if (entry.hours <= 0) {
204
+ throw new Error('Logged hours must be a positive number.');
205
+ }
206
+
207
+ this.loggedHours = (this.loggedHours || 0) + entry.hours;
208
+ this.actualHours = (this.actualHours || 0) + entry.hours;
209
+ if (entry.isBillable !== false) {
210
+ this.billableHours = (this.billableHours || 0) + entry.hours;
211
+ }
212
+
213
+ const now = new Date().toISOString();
214
+ this.markUpdated();
215
+
216
+ const timeEntry: TimeEntry = {
217
+ id: `time_${Math.random().toString(36).substring(2, 9)}`,
218
+ taskId: this.id,
219
+ userId: entry.userId || this.assigneeId || 'system',
220
+ hours: entry.hours,
221
+ isBillable: entry.isBillable ?? true,
222
+ description: entry.description,
223
+ loggedAt: now
224
+ };
225
+
226
+ const event: TimeLoggedEvent = {
227
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
228
+ name: 'time.logged',
229
+ aggregateId: this.id,
230
+ aggregateType: 'Task',
231
+ occurredAt: now,
232
+ payload: {
233
+ taskId: this.id,
234
+ timeEntry
235
+ }
236
+ };
237
+ this.raiseEvent(event);
238
+
239
+ return timeEntry;
240
+ }
241
+
242
+ public update(updates: Partial<Task>, customFieldDefs?: CustomFieldDefinition[]): this {
243
+ const previous = this.toPlain();
244
+
245
+ if (updates.customFields && customFieldDefs) {
246
+ validateCustomFieldValues(customFieldDefs, {
247
+ ...this.customFields,
248
+ ...updates.customFields
249
+ });
250
+ }
251
+
252
+ Object.assign(this, updates);
253
+ this.markUpdated();
254
+
255
+ const event: TaskUpdatedEvent = {
256
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
257
+ name: 'task.updated',
258
+ aggregateId: this.id,
259
+ aggregateType: 'Task',
260
+ occurredAt: new Date().toISOString(),
261
+ payload: {
262
+ task: this.toPlain(),
263
+ previous
264
+ }
265
+ };
266
+ this.raiseEvent(event);
267
+
268
+ return this;
269
+ }
270
+
271
+ public toPlain(): Task {
272
+ return {
273
+ id: this.id,
274
+ projectId: this.projectId,
275
+ key: this.key,
276
+ title: this.title,
277
+ description: this.description,
278
+ status: this.status,
279
+ priority: this.priority,
280
+ taskType: this.taskType,
281
+ assigneeId: this.assigneeId,
282
+ reporterId: this.reporterId,
283
+ reviewerId: this.reviewerId,
284
+ iterationId: this.iterationId,
285
+ teamId: this.teamId,
286
+ containerId: this.containerId,
287
+ plannedStartDate: this.plannedStartDate,
288
+ actualStartDate: this.actualStartDate,
289
+ actualEndDate: this.actualEndDate,
290
+ dueDate: this.dueDate,
291
+ estimatedHours: this.estimatedHours,
292
+ loggedHours: this.loggedHours,
293
+ actualHours: this.actualHours,
294
+ billableHours: this.billableHours,
295
+ estimatedDurationMinutes: this.estimatedDurationMinutes,
296
+ actualDurationMinutes: this.actualDurationMinutes,
297
+ billableDurationMinutes: this.billableDurationMinutes,
298
+ progress: this.progress,
299
+ tags: this.tags ? [...this.tags] : [],
300
+ customFields: this.customFields ? { ...this.customFields } : {},
301
+ parentId: this.parentId,
302
+ createdAt: this.createdAt,
303
+ updatedAt: this.updatedAt
304
+ };
305
+ }
306
+ }
307
+
308
+ export class ProjectEntity extends BaseEntity {
309
+ public key: string;
310
+ public name: string;
311
+ public description?: string;
312
+ public ownerId?: string;
313
+ public members?: string[];
314
+ public teamIds?: string[];
315
+ public workflowId?: string;
316
+ public workflow?: Workflow;
317
+ public taskTypes?: Project['taskTypes'];
318
+ public statusDefinitions?: Project['statusDefinitions'];
319
+ public priorityDefinitions?: Project['priorityDefinitions'];
320
+ public customFieldDefinitions?: CustomFieldDefinition[];
321
+
322
+ constructor(data: Project) {
323
+ super(data.id, data.createdAt, data.updatedAt);
324
+ this.key = data.key || generateProjectKey(data.name);
325
+ this.name = data.name;
326
+ this.description = data.description;
327
+ this.ownerId = data.ownerId;
328
+ this.members = data.members ? [...data.members] : [];
329
+ this.teamIds = data.teamIds ? [...data.teamIds] : [];
330
+ this.workflowId = data.workflowId;
331
+ this.workflow = data.workflow;
332
+ this.taskTypes = data.taskTypes;
333
+ this.statusDefinitions = data.statusDefinitions;
334
+ this.priorityDefinitions = data.priorityDefinitions;
335
+ this.customFieldDefinitions = data.customFieldDefinitions ? [...data.customFieldDefinitions] : [];
336
+ }
337
+
338
+ public static create(
339
+ input: CreateProjectInput,
340
+ options?: { id?: string }
341
+ ): ProjectEntity {
342
+ const id = options?.id || `proj_${Math.random().toString(36).substring(2, 9)}`;
343
+ const now = new Date().toISOString();
344
+ const key = input.key || generateProjectKey(input.name);
345
+
346
+ const project = new ProjectEntity({
347
+ ...input,
348
+ id,
349
+ key,
350
+ createdAt: now,
351
+ updatedAt: now
352
+ });
353
+
354
+ const event: ProjectCreatedEvent = {
355
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
356
+ name: 'project.created',
357
+ aggregateId: project.id,
358
+ aggregateType: 'Project',
359
+ occurredAt: now,
360
+ payload: { project: project.toPlain() }
361
+ };
362
+ project.raiseEvent(event);
363
+
364
+ return project;
365
+ }
366
+
367
+ public validateCustomFields(values?: Record<string, unknown>): void {
368
+ validateCustomFieldValues(this.customFieldDefinitions, values);
369
+ }
370
+
371
+ public toPlain(): Project {
372
+ return {
373
+ id: this.id,
374
+ key: this.key,
375
+ name: this.name,
376
+ description: this.description,
377
+ ownerId: this.ownerId,
378
+ members: this.members ? [...this.members] : [],
379
+ teamIds: this.teamIds ? [...this.teamIds] : [],
380
+ workflowId: this.workflowId,
381
+ workflow: this.workflow,
382
+ taskTypes: this.taskTypes,
383
+ statusDefinitions: this.statusDefinitions,
384
+ priorityDefinitions: this.priorityDefinitions,
385
+ customFieldDefinitions: this.customFieldDefinitions ? [...this.customFieldDefinitions] : [],
386
+ createdAt: this.createdAt,
387
+ updatedAt: this.updatedAt
388
+ };
389
+ }
390
+ }
@@ -0,0 +1,196 @@
1
+ import type {
2
+ Task,
3
+ Project,
4
+ Workflow,
5
+ Iteration,
6
+ Team,
7
+ TaskContainer,
8
+ TaskDependency,
9
+ TimeEntry,
10
+ Comment,
11
+ Attachment
12
+ } from '../types/index.js';
13
+
14
+ export interface DomainEvent<TPayload = Record<string, unknown>> {
15
+ readonly id: string;
16
+ readonly name: string;
17
+ readonly aggregateId: string;
18
+ readonly aggregateType: 'Task' | 'Project' | 'Workflow' | 'Iteration' | 'Team' | 'Container' | 'Dependency' | 'Comment' | 'Attachment';
19
+ readonly occurredAt: string;
20
+ readonly payload: TPayload;
21
+ }
22
+
23
+ // Concrete Domain Events
24
+ export interface TaskCreatedEvent extends DomainEvent<{ task: Task }> {
25
+ readonly name: 'task.created';
26
+ readonly aggregateType: 'Task';
27
+ }
28
+
29
+ export interface TaskUpdatedEvent extends DomainEvent<{ task: Task; previous: Task }> {
30
+ readonly name: 'task.updated';
31
+ readonly aggregateType: 'Task';
32
+ }
33
+
34
+ export interface TaskStatusChangedEvent extends DomainEvent<{
35
+ task: Task;
36
+ previousStatus: string;
37
+ newStatus: string;
38
+ }> {
39
+ readonly name: 'task.status_changed';
40
+ readonly aggregateType: 'Task';
41
+ }
42
+
43
+ export interface TaskDeletedEvent extends DomainEvent<{ taskId: string; projectId: string; title: string }> {
44
+ readonly name: 'task.deleted';
45
+ readonly aggregateType: 'Task';
46
+ }
47
+
48
+ export interface TimeLoggedEvent extends DomainEvent<{ timeEntry: TimeEntry; taskId: string }> {
49
+ readonly name: 'time.logged';
50
+ readonly aggregateType: 'Task';
51
+ }
52
+
53
+ export interface CommentAddedEvent extends DomainEvent<{ comment: Comment; taskId: string }> {
54
+ readonly name: 'comment.created';
55
+ readonly aggregateType: 'Comment';
56
+ }
57
+
58
+ export interface CommentUpdatedEvent extends DomainEvent<{ comment: Comment; previous: Comment }> {
59
+ readonly name: 'comment.updated';
60
+ readonly aggregateType: 'Comment';
61
+ }
62
+
63
+ export interface CommentDeletedEvent extends DomainEvent<{ commentId: string; taskId: string }> {
64
+ readonly name: 'comment.deleted';
65
+ readonly aggregateType: 'Comment';
66
+ }
67
+
68
+ export interface AttachmentCreatedEvent extends DomainEvent<{ attachment: Attachment }> {
69
+ readonly name: 'attachment.created';
70
+ readonly aggregateType: 'Attachment';
71
+ }
72
+
73
+ export interface AttachmentDeletedEvent extends DomainEvent<{ attachmentId: string; storageKey?: string; url: string }> {
74
+ readonly name: 'attachment.deleted';
75
+ readonly aggregateType: 'Attachment';
76
+ }
77
+
78
+ export interface TaskDependencyAddedEvent extends DomainEvent<{ dependency: TaskDependency }> {
79
+ readonly name: 'dependency.added';
80
+ readonly aggregateType: 'Dependency';
81
+ }
82
+
83
+ export interface ProjectCreatedEvent extends DomainEvent<{ project: Project }> {
84
+ readonly name: 'project.created';
85
+ readonly aggregateType: 'Project';
86
+ }
87
+
88
+ export interface ProjectUpdatedEvent extends DomainEvent<{ project: Project; previous: Project }> {
89
+ readonly name: 'project.updated';
90
+ readonly aggregateType: 'Project';
91
+ }
92
+
93
+ export interface WorkflowCreatedEvent extends DomainEvent<{ workflow: Workflow }> {
94
+ readonly name: 'workflow.created';
95
+ readonly aggregateType: 'Workflow';
96
+ }
97
+
98
+ export interface WorkflowUpdatedEvent extends DomainEvent<{ workflow: Workflow; previous: Workflow }> {
99
+ readonly name: 'workflow.updated';
100
+ readonly aggregateType: 'Workflow';
101
+ }
102
+
103
+ export interface WorkflowDeletedEvent extends DomainEvent<{ workflowId: string; name: string }> {
104
+ readonly name: 'workflow.deleted';
105
+ readonly aggregateType: 'Workflow';
106
+ }
107
+
108
+ export interface IterationStartedEvent extends DomainEvent<{ iteration: Iteration }> {
109
+ readonly name: 'iteration.started';
110
+ readonly aggregateType: 'Iteration';
111
+ }
112
+
113
+ export interface IterationCompletedEvent extends DomainEvent<{ iteration: Iteration }> {
114
+ readonly name: 'iteration.completed';
115
+ readonly aggregateType: 'Iteration';
116
+ }
117
+
118
+ export interface TeamCreatedEvent extends DomainEvent<{ team: Team }> {
119
+ readonly name: 'team.created';
120
+ readonly aggregateType: 'Team';
121
+ }
122
+
123
+ export interface ContainerCreatedEvent extends DomainEvent<{ container: TaskContainer }> {
124
+ readonly name: 'container.created';
125
+ readonly aggregateType: 'Container';
126
+ }
127
+
128
+ export type CriticalPathDomainEvent =
129
+ | TaskCreatedEvent
130
+ | TaskUpdatedEvent
131
+ | TaskStatusChangedEvent
132
+ | TaskDeletedEvent
133
+ | TimeLoggedEvent
134
+ | CommentAddedEvent
135
+ | CommentUpdatedEvent
136
+ | CommentDeletedEvent
137
+ | AttachmentCreatedEvent
138
+ | AttachmentDeletedEvent
139
+ | TaskDependencyAddedEvent
140
+ | ProjectCreatedEvent
141
+ | ProjectUpdatedEvent
142
+ | WorkflowCreatedEvent
143
+ | WorkflowUpdatedEvent
144
+ | WorkflowDeletedEvent
145
+ | IterationStartedEvent
146
+ | IterationCompletedEvent
147
+ | TeamCreatedEvent
148
+ | ContainerCreatedEvent;
149
+
150
+ export type DomainEventHandler<T extends DomainEvent = DomainEvent> = (event: T) => Promise<void> | void;
151
+
152
+ export class DomainEventBus {
153
+ private handlers = new Map<string, Array<DomainEventHandler<any>>>();
154
+
155
+ subscribe<T extends DomainEvent = DomainEvent>(
156
+ eventName: T['name'] | '*',
157
+ handler: DomainEventHandler<T>
158
+ ): () => void {
159
+ const list = this.handlers.get(eventName) || [];
160
+ list.push(handler);
161
+ this.handlers.set(eventName, list);
162
+
163
+ return () => {
164
+ const currentList = this.handlers.get(eventName) || [];
165
+ this.handlers.set(
166
+ eventName,
167
+ currentList.filter((h) => h !== handler)
168
+ );
169
+ };
170
+ }
171
+
172
+ async publish(event: DomainEvent): Promise<void> {
173
+ const directHandlers = this.handlers.get(event.name) || [];
174
+ const wildcardHandlers = this.handlers.get('*') || [];
175
+ const allHandlers = [...directHandlers, ...wildcardHandlers];
176
+
177
+ for (const handler of allHandlers) {
178
+ try {
179
+ await handler(event);
180
+ } catch (err) {
181
+ // Prevent subscriber failures from breaking event pipeline
182
+ console.error(`[DomainEventBus] Handler error on event "${event.name}":`, err);
183
+ }
184
+ }
185
+ }
186
+
187
+ async publishAll(events: DomainEvent[]): Promise<void> {
188
+ for (const event of events) {
189
+ await this.publish(event);
190
+ }
191
+ }
192
+
193
+ clear(): void {
194
+ this.handlers.clear();
195
+ }
196
+ }
@@ -0,0 +1,70 @@
1
+ import type { TaskDependency } from '../types/index.js';
2
+
3
+ export class CircularDependencyError extends Error {
4
+ public readonly taskId: string;
5
+ public readonly dependsOnTaskId: string;
6
+ public readonly cyclePath?: string[];
7
+
8
+ constructor(taskId: string, dependsOnTaskId: string, cyclePath?: string[]) {
9
+ const pathStr = cyclePath ? ` (Cycle detected: ${cyclePath.join(' -> ')})` : '';
10
+ super(`Cannot add dependency from "${taskId}" to "${dependsOnTaskId}" because it would create a circular dependency cycle${pathStr}.`);
11
+ this.name = 'CircularDependencyError';
12
+ this.taskId = taskId;
13
+ this.dependsOnTaskId = dependsOnTaskId;
14
+ this.cyclePath = cyclePath;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Validates that adding a dependency from taskId to dependsOnTaskId will not create a cycle in the task graph.
20
+ * A cycle occurs if dependsOnTaskId already transitively depends on taskId.
21
+ */
22
+ export function detectDependencyCycle(
23
+ existingDependencies: TaskDependency[],
24
+ newDependency: { taskId: string; dependsOnTaskId: string }
25
+ ): { hasCycle: boolean; cyclePath?: string[] } {
26
+ const { taskId, dependsOnTaskId } = newDependency;
27
+
28
+ // Self-dependency check
29
+ if (taskId === dependsOnTaskId) {
30
+ return { hasCycle: true, cyclePath: [taskId, taskId] };
31
+ }
32
+
33
+ // Build adjacency list: node -> array of nodes it depends on
34
+ const adj = new Map<string, string[]>();
35
+ for (const dep of existingDependencies) {
36
+ const targets = adj.get(dep.taskId) || [];
37
+ targets.push(dep.dependsOnTaskId);
38
+ adj.set(dep.taskId, targets);
39
+ }
40
+
41
+ // Check if dependsOnTaskId can reach taskId via existing dependency paths
42
+ const visited = new Set<string>();
43
+ const path: string[] = [taskId, dependsOnTaskId];
44
+
45
+ function dfs(current: string): boolean {
46
+ if (current === taskId) {
47
+ return true;
48
+ }
49
+ if (visited.has(current)) {
50
+ return false;
51
+ }
52
+ visited.add(current);
53
+
54
+ const neighbors = adj.get(current) || [];
55
+ for (const next of neighbors) {
56
+ path.push(next);
57
+ if (dfs(next)) {
58
+ return true;
59
+ }
60
+ path.pop();
61
+ }
62
+ return false;
63
+ }
64
+
65
+ const hasCycle = dfs(dependsOnTaskId);
66
+ return {
67
+ hasCycle,
68
+ cyclePath: hasCycle ? path : undefined
69
+ };
70
+ }
@@ -0,0 +1,4 @@
1
+ export * from './events.js';
2
+ export * from './custom-fields.js';
3
+ export * from './graph.js';
4
+ export * from './entities.js';