@critical-path/core 0.32.0 → 0.34.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 (50) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +12 -1
  3. package/dist/domain/entities.js +17 -17
  4. package/dist/domain/entities.js.map +1 -1
  5. package/dist/domain/events.d.ts +53 -2
  6. package/dist/domain/events.d.ts.map +1 -1
  7. package/dist/domain/events.js +7 -0
  8. package/dist/domain/events.js.map +1 -1
  9. package/dist/engine/entity-events.test.d.ts +2 -0
  10. package/dist/engine/entity-events.test.d.ts.map +1 -0
  11. package/dist/engine/entity-events.test.js +61 -0
  12. package/dist/engine/entity-events.test.js.map +1 -0
  13. package/dist/engine/index.d.ts +2 -0
  14. package/dist/engine/index.d.ts.map +1 -1
  15. package/dist/engine/index.js +106 -39
  16. package/dist/engine/index.js.map +1 -1
  17. package/dist/schemas/index.d.ts +6 -6
  18. package/dist/store/conformance.test.d.ts +2 -0
  19. package/dist/store/conformance.test.d.ts.map +1 -0
  20. package/dist/store/conformance.test.js +15 -0
  21. package/dist/store/conformance.test.js.map +1 -0
  22. package/dist/store/firebase.d.ts.map +1 -1
  23. package/dist/store/firebase.js +14 -3
  24. package/dist/store/firebase.js.map +1 -1
  25. package/dist/store/index.js +13 -13
  26. package/dist/store/index.js.map +1 -1
  27. package/dist/store/sqlite.d.ts +6 -0
  28. package/dist/store/sqlite.d.ts.map +1 -1
  29. package/dist/store/sqlite.js +59 -18
  30. package/dist/store/sqlite.js.map +1 -1
  31. package/dist/testing/conformance.d.ts +36 -0
  32. package/dist/testing/conformance.d.ts.map +1 -0
  33. package/dist/testing/conformance.js +244 -0
  34. package/dist/testing/conformance.js.map +1 -0
  35. package/dist/testing/index.d.ts +2 -0
  36. package/dist/testing/index.d.ts.map +1 -0
  37. package/dist/testing/index.js +2 -0
  38. package/dist/testing/index.js.map +1 -0
  39. package/package.json +5 -1
  40. package/src/domain/entities.ts +17 -17
  41. package/src/domain/events.ts +49 -0
  42. package/src/engine/entity-events.test.ts +67 -0
  43. package/src/engine/index.ts +115 -39
  44. package/src/store/conformance.test.ts +15 -0
  45. package/src/store/firebase.ts +17 -3
  46. package/src/store/index.ts +13 -13
  47. package/src/store/sqlite.ts +67 -18
  48. package/src/testing/conformance.ts +301 -0
  49. package/src/testing/index.ts +1 -0
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -146,7 +146,7 @@ export class TaskEntity extends BaseEntity {
146
146
  input: CreateTaskInput,
147
147
  options?: { id?: string; workflow?: Workflow; customFieldDefs?: CustomFieldDefinition[] }
148
148
  ): TaskEntity {
149
- const id = options?.id || `task_${Math.random().toString(36).substring(2, 9)}`;
149
+ const id = options?.id || `task_${crypto.randomUUID()}`;
150
150
  const now = new Date().toISOString();
151
151
 
152
152
  if (options?.customFieldDefs) {
@@ -186,7 +186,7 @@ export class TaskEntity extends BaseEntity {
186
186
  });
187
187
 
188
188
  const createdEvent: TaskCreatedEvent = {
189
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
189
+ id: `evt_${crypto.randomUUID()}`,
190
190
  name: 'task.created',
191
191
  aggregateId: task.id,
192
192
  aggregateType: 'Task',
@@ -277,7 +277,7 @@ export class TaskEntity extends BaseEntity {
277
277
  this.markUpdated();
278
278
 
279
279
  const event: TaskStatusChangedEvent = {
280
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
280
+ id: `evt_${crypto.randomUUID()}`,
281
281
  name: 'task.status_changed',
282
282
  aggregateId: this.id,
283
283
  aggregateType: 'Task',
@@ -310,7 +310,7 @@ export class TaskEntity extends BaseEntity {
310
310
  this.markUpdated();
311
311
 
312
312
  const timeEntry: TimeEntry = {
313
- id: `time_${Math.random().toString(36).substring(2, 9)}`,
313
+ id: `time_${crypto.randomUUID()}`,
314
314
  taskId: this.id,
315
315
  userId: entry.userId || this.assigneeId || 'system',
316
316
  hours: entry.hours,
@@ -320,7 +320,7 @@ export class TaskEntity extends BaseEntity {
320
320
  };
321
321
 
322
322
  const event: TimeLoggedEvent = {
323
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
323
+ id: `evt_${crypto.randomUUID()}`,
324
324
  name: 'time.logged',
325
325
  aggregateId: this.id,
326
326
  aggregateType: 'Task',
@@ -338,7 +338,7 @@ export class TaskEntity extends BaseEntity {
338
338
  public addTodo(title: string, id?: string): TaskTodoItem {
339
339
  const previous = this.toPlain();
340
340
  const item: TaskTodoItem = {
341
- id: id || `todo_${Math.random().toString(36).substring(2, 9)}`,
341
+ id: id || `todo_${crypto.randomUUID()}`,
342
342
  title,
343
343
  completed: false,
344
344
  createdAt: new Date().toISOString()
@@ -350,7 +350,7 @@ export class TaskEntity extends BaseEntity {
350
350
  this.markUpdated();
351
351
 
352
352
  const event: TaskUpdatedEvent = {
353
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
353
+ id: `evt_${crypto.randomUUID()}`,
354
354
  name: 'task.updated',
355
355
  aggregateId: this.id,
356
356
  aggregateType: 'Task',
@@ -375,7 +375,7 @@ export class TaskEntity extends BaseEntity {
375
375
  this.markUpdated();
376
376
 
377
377
  const event: TaskUpdatedEvent = {
378
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
378
+ id: `evt_${crypto.randomUUID()}`,
379
379
  name: 'task.updated',
380
380
  aggregateId: this.id,
381
381
  aggregateType: 'Task',
@@ -397,7 +397,7 @@ export class TaskEntity extends BaseEntity {
397
397
  this.markUpdated();
398
398
 
399
399
  const event: TaskUpdatedEvent = {
400
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
400
+ id: `evt_${crypto.randomUUID()}`,
401
401
  name: 'task.updated',
402
402
  aggregateId: this.id,
403
403
  aggregateType: 'Task',
@@ -459,7 +459,7 @@ export class TaskEntity extends BaseEntity {
459
459
  this.markUpdated();
460
460
 
461
461
  const event: TaskUpdatedEvent = {
462
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
462
+ id: `evt_${crypto.randomUUID()}`,
463
463
  name: 'task.updated',
464
464
  aggregateId: this.id,
465
465
  aggregateType: 'Task',
@@ -554,7 +554,7 @@ export class ProjectEntity extends BaseEntity {
554
554
  input: CreateProjectInput,
555
555
  options?: { id?: string }
556
556
  ): ProjectEntity {
557
- const id = options?.id || `proj_${Math.random().toString(36).substring(2, 9)}`;
557
+ const id = options?.id || `proj_${crypto.randomUUID()}`;
558
558
  const now = new Date().toISOString();
559
559
  const key = input.key || generateProjectKey(input.name);
560
560
 
@@ -567,7 +567,7 @@ export class ProjectEntity extends BaseEntity {
567
567
  });
568
568
 
569
569
  const event: ProjectCreatedEvent = {
570
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
570
+ id: `evt_${crypto.randomUUID()}`,
571
571
  name: 'project.created',
572
572
  aggregateId: project.id,
573
573
  aggregateType: 'Project',
@@ -638,7 +638,7 @@ export class DeliverableEntity extends BaseEntity {
638
638
  input: CreateDeliverableInput,
639
639
  options?: { id?: string; customFieldDefs?: CustomFieldDefinition[] }
640
640
  ): DeliverableEntity {
641
- const id = options?.id || `deliv_${Math.random().toString(36).substring(2, 9)}`;
641
+ const id = options?.id || `deliv_${crypto.randomUUID()}`;
642
642
  const now = new Date().toISOString();
643
643
 
644
644
  if (options?.customFieldDefs) {
@@ -656,7 +656,7 @@ export class DeliverableEntity extends BaseEntity {
656
656
  });
657
657
 
658
658
  const createdEvent: DeliverableCreatedEvent = {
659
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
659
+ id: `evt_${crypto.randomUUID()}`,
660
660
  name: 'deliverable.created',
661
661
  aggregateId: deliverable.id,
662
662
  aggregateType: 'Deliverable',
@@ -682,7 +682,7 @@ export class DeliverableEntity extends BaseEntity {
682
682
  this.markUpdated();
683
683
 
684
684
  const event: DeliverableStatusChangedEvent = {
685
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
685
+ id: `evt_${crypto.randomUUID()}`,
686
686
  name: 'deliverable.status_changed',
687
687
  aggregateId: this.id,
688
688
  aggregateType: 'Deliverable',
@@ -708,7 +708,7 @@ export class DeliverableEntity extends BaseEntity {
708
708
  this.markUpdated();
709
709
 
710
710
  const event: DeliverableUpdatedEvent = {
711
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
711
+ id: `evt_${crypto.randomUUID()}`,
712
712
  name: 'deliverable.updated',
713
713
  aggregateId: this.id,
714
714
  aggregateType: 'Deliverable',
@@ -742,7 +742,7 @@ export class DeliverableEntity extends BaseEntity {
742
742
  this.markUpdated();
743
743
 
744
744
  const event: DeliverableUpdatedEvent = {
745
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
745
+ id: `evt_${crypto.randomUUID()}`,
746
746
  name: 'deliverable.updated',
747
747
  aggregateId: this.id,
748
748
  aggregateType: 'Deliverable',
@@ -174,6 +174,41 @@ export interface TeamCreatedEvent extends DomainEvent<{ team: Team }> {
174
174
  readonly aggregateType: 'Team';
175
175
  }
176
176
 
177
+ export interface TeamUpdatedEvent extends DomainEvent<{ team: Team; previous: Team }> {
178
+ readonly name: 'team.updated';
179
+ readonly aggregateType: 'Team';
180
+ }
181
+
182
+ export interface TeamDeletedEvent extends DomainEvent<{ teamId: string; name: string; tenantId?: string }> {
183
+ readonly name: 'team.deleted';
184
+ readonly aggregateType: 'Team';
185
+ }
186
+
187
+ export interface ContainerUpdatedEvent extends DomainEvent<{ container: TaskContainer; previous: TaskContainer }> {
188
+ readonly name: 'container.updated';
189
+ readonly aggregateType: 'Container';
190
+ }
191
+
192
+ export interface ContainerDeletedEvent extends DomainEvent<{ containerId: string; projectId: string; name: string }> {
193
+ readonly name: 'container.deleted';
194
+ readonly aggregateType: 'Container';
195
+ }
196
+
197
+ export interface IterationCreatedEvent extends DomainEvent<{ iteration: Iteration }> {
198
+ readonly name: 'iteration.created';
199
+ readonly aggregateType: 'Iteration';
200
+ }
201
+
202
+ export interface IterationUpdatedEvent extends DomainEvent<{ iteration: Iteration; previous: Iteration }> {
203
+ readonly name: 'iteration.updated';
204
+ readonly aggregateType: 'Iteration';
205
+ }
206
+
207
+ export interface IterationDeletedEvent extends DomainEvent<{ iterationId: string; projectId: string; name: string }> {
208
+ readonly name: 'iteration.deleted';
209
+ readonly aggregateType: 'Iteration';
210
+ }
211
+
177
212
  export interface ContainerCreatedEvent extends DomainEvent<{ container: TaskContainer }> {
178
213
  readonly name: 'container.created';
179
214
  readonly aggregateType: 'Container';
@@ -225,7 +260,14 @@ export type CriticalPathDomainEvent =
225
260
  | IterationStartedEvent
226
261
  | IterationCompletedEvent
227
262
  | TeamCreatedEvent
263
+ | TeamUpdatedEvent
264
+ | TeamDeletedEvent
228
265
  | ContainerCreatedEvent
266
+ | ContainerUpdatedEvent
267
+ | ContainerDeletedEvent
268
+ | IterationCreatedEvent
269
+ | IterationUpdatedEvent
270
+ | IterationDeletedEvent
229
271
  | DeliverableCreatedEvent
230
272
  | DeliverableUpdatedEvent
231
273
  | DeliverableStatusChangedEvent
@@ -289,6 +331,8 @@ export const DOMAIN_EVENT_NAMES = [
289
331
  'comment.reaction.removed',
290
332
  'comment.updated',
291
333
  'container.created',
334
+ 'container.deleted',
335
+ 'container.updated',
292
336
  'deliverable.created',
293
337
  'deliverable.deleted',
294
338
  'deliverable.status_changed',
@@ -296,7 +340,10 @@ export const DOMAIN_EVENT_NAMES = [
296
340
  'dependency.added',
297
341
  'dependency.removed',
298
342
  'iteration.completed',
343
+ 'iteration.created',
344
+ 'iteration.deleted',
299
345
  'iteration.started',
346
+ 'iteration.updated',
300
347
  'project.created',
301
348
  'project.deleted',
302
349
  'project.updated',
@@ -307,6 +354,8 @@ export const DOMAIN_EVENT_NAMES = [
307
354
  'task.unblocked',
308
355
  'task.updated',
309
356
  'team.created',
357
+ 'team.deleted',
358
+ 'team.updated',
310
359
  'time.logged',
311
360
  'workflow.created',
312
361
  'workflow.deleted',
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { CriticalPathEngine, type DomainEvent } from '../index.js';
3
+
4
+ function record(engine: CriticalPathEngine) {
5
+ const events: DomainEvent[] = [];
6
+ engine.events.subscribe('*', (e) => void events.push(e));
7
+ return events;
8
+ }
9
+
10
+ describe('team, container and iteration events', () => {
11
+ it('publishes updated and deleted events with the previous state', async () => {
12
+ const engine = new CriticalPathEngine();
13
+ const project = await engine.createProject({ name: 'Events' });
14
+ const team = await engine.createTeam({ name: 'Core', memberIds: [] });
15
+ const container = await engine.createContainer({ projectId: project.id, name: 'Epic' });
16
+ const events = record(engine);
17
+ const iteration = await engine.createIteration({ projectId: project.id, name: 'S1', status: 'planning' });
18
+
19
+ await engine.updateTeam(team.id, { name: 'Platform' });
20
+ await engine.updateContainer(container.id, { name: 'Epic 2' });
21
+ await engine.updateIteration(iteration.id, { goal: 'Ship' });
22
+ await engine.deleteTeam(team.id);
23
+ await engine.deleteContainer(container.id);
24
+ await engine.deleteIteration(iteration.id);
25
+
26
+ expect(events.map((e) => e.name)).toEqual([
27
+ 'iteration.created',
28
+ 'team.updated',
29
+ 'container.updated',
30
+ 'iteration.updated',
31
+ 'team.deleted',
32
+ 'container.deleted',
33
+ 'iteration.deleted'
34
+ ]);
35
+ const teamUpdated = events.find((e) => e.name === 'team.updated')!.payload as any;
36
+ expect(teamUpdated.previous.name).toBe('Core');
37
+ expect(teamUpdated.team.name).toBe('Platform');
38
+ expect(events.find((e) => e.name === 'iteration.deleted')!.payload).toMatchObject({ projectId: project.id, name: 'S1' });
39
+ expect(new Set(events.map((e) => e.id)).size).toBe(events.length);
40
+ });
41
+
42
+ it('routes deletion webhooks to the owning tenant', async () => {
43
+ const delivered: Array<{ url: string; event: string }> = [];
44
+ const engine = new CriticalPathEngine({
45
+ webhookDelivery: {
46
+ fetch: (async (url: string, init: RequestInit) => {
47
+ delivered.push({ url, event: JSON.parse(init.body as string).event });
48
+ return new Response(null, { status: 204 });
49
+ }) as unknown as typeof fetch
50
+ }
51
+ });
52
+ const acme = engine.withActor({ userId: 'a', tenantId: 'acme' });
53
+ const globex = engine.withActor({ userId: 'g', tenantId: 'globex' });
54
+ await acme.createWebhook({ name: 'Acme', url: 'https://hooks.example.com/acme', events: ['team.deleted', 'container.deleted'] });
55
+ await globex.createWebhook({ name: 'Globex', url: 'https://hooks.example.com/globex', events: ['*'] });
56
+
57
+ const project = await acme.createProject({ name: 'Acme' });
58
+ const team = await acme.createTeam({ name: 'Team', memberIds: [] });
59
+ const container = await acme.createContainer({ projectId: project.id, name: 'C' });
60
+ await acme.deleteTeam(team.id);
61
+ await acme.deleteContainer(container.id);
62
+ await engine.webhooks.idle();
63
+
64
+ expect(delivered.map((d) => d.event).sort()).toEqual(['container.deleted', 'team.deleted']);
65
+ expect(delivered.every((d) => d.url.endsWith('/acme'))).toBe(true);
66
+ });
67
+ });