@critical-path/core 0.33.0 → 0.35.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 (38) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -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/firebase.js +5 -5
  19. package/dist/store/firebase.js.map +1 -1
  20. package/dist/store/index.d.ts +5 -0
  21. package/dist/store/index.d.ts.map +1 -1
  22. package/dist/store/index.js +30 -13
  23. package/dist/store/index.js.map +1 -1
  24. package/dist/store/sqlite.js +13 -13
  25. package/dist/store/sqlite.js.map +1 -1
  26. package/dist/testing/conformance.d.ts.map +1 -1
  27. package/dist/testing/conformance.js +15 -0
  28. package/dist/testing/conformance.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/domain/entities.ts +17 -17
  31. package/src/domain/events.ts +49 -0
  32. package/src/engine/entity-events.test.ts +67 -0
  33. package/src/engine/index.ts +115 -39
  34. package/src/store/firebase.ts +5 -5
  35. package/src/store/index.ts +30 -13
  36. package/src/store/sqlite.ts +13 -13
  37. package/src/testing/conformance.ts +18 -0
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -63,6 +63,14 @@ import {
63
63
  ProjectUpdatedEvent,
64
64
  ProjectDeletedEvent,
65
65
  TaskDependencyRemovedEvent,
66
+ TeamUpdatedEvent,
67
+ TeamDeletedEvent,
68
+ ContainerUpdatedEvent,
69
+ ContainerDeletedEvent,
70
+ IterationCreatedEvent,
71
+ IterationUpdatedEvent,
72
+ IterationDeletedEvent,
73
+ CriticalPathDomainEvent,
66
74
  WorkflowCreatedEvent,
67
75
  WorkflowUpdatedEvent,
68
76
  WorkflowDeletedEvent,
@@ -161,6 +169,15 @@ export class CriticalPathEngine {
161
169
  return scoped;
162
170
  }
163
171
 
172
+ /** Publishes a domain event, filling in its id and timestamp. */
173
+ private async publishEvent<E extends CriticalPathDomainEvent>(event: Omit<E, 'id' | 'occurredAt'>): Promise<void> {
174
+ await this.events.publish({
175
+ ...event,
176
+ id: `evt_${crypto.randomUUID()}`,
177
+ occurredAt: new Date().toISOString()
178
+ } as E);
179
+ }
180
+
164
181
  private actorIdOr(fallback: string): string {
165
182
  return this.actor?.userId ?? fallback;
166
183
  }
@@ -335,7 +352,7 @@ export class CriticalPathEngine {
335
352
  const now = new Date().toISOString();
336
353
 
337
354
  const event: WorkflowCreatedEvent = {
338
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
355
+ id: `evt_${crypto.randomUUID()}`,
339
356
  name: 'workflow.created',
340
357
  aggregateId: created.id,
341
358
  aggregateType: 'Workflow',
@@ -362,7 +379,7 @@ export class CriticalPathEngine {
362
379
  if (updated) {
363
380
  const now = new Date().toISOString();
364
381
  const event: WorkflowUpdatedEvent = {
365
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
382
+ id: `evt_${crypto.randomUUID()}`,
366
383
  name: 'workflow.updated',
367
384
  aggregateId: updated.id,
368
385
  aggregateType: 'Workflow',
@@ -389,7 +406,7 @@ export class CriticalPathEngine {
389
406
  if (deleted) {
390
407
  const now = new Date().toISOString();
391
408
  const event: WorkflowDeletedEvent = {
392
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
409
+ id: `evt_${crypto.randomUUID()}`,
393
410
  name: 'workflow.deleted',
394
411
  aggregateId: id,
395
412
  aggregateType: 'Workflow',
@@ -469,7 +486,7 @@ export class CriticalPathEngine {
469
486
  const now = new Date().toISOString();
470
487
 
471
488
  const event: ProjectCreatedEvent = {
472
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
489
+ id: `evt_${crypto.randomUUID()}`,
473
490
  name: 'project.created',
474
491
  aggregateId: created.id,
475
492
  aggregateType: 'Project',
@@ -499,7 +516,7 @@ export class CriticalPathEngine {
499
516
  if (updated) {
500
517
  const now = new Date().toISOString();
501
518
  const event: ProjectUpdatedEvent = {
502
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
519
+ id: `evt_${crypto.randomUUID()}`,
503
520
  name: 'project.updated',
504
521
  aggregateId: updated.id,
505
522
  aggregateType: 'Project',
@@ -547,7 +564,7 @@ export class CriticalPathEngine {
547
564
  if (deleted) {
548
565
  const now = new Date().toISOString();
549
566
  const event: ProjectDeletedEvent = {
550
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
567
+ id: `evt_${crypto.randomUUID()}`,
551
568
  name: 'project.deleted',
552
569
  aggregateId: id,
553
570
  aggregateType: 'Project',
@@ -688,7 +705,7 @@ export class CriticalPathEngine {
688
705
 
689
706
  // Publish typed Domain Event
690
707
  const event: TaskCreatedEvent = {
691
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
708
+ id: `evt_${crypto.randomUUID()}`,
692
709
  name: 'task.created',
693
710
  aggregateId: created.id,
694
711
  aggregateType: 'Task',
@@ -927,7 +944,7 @@ export class CriticalPathEngine {
927
944
  // Publish typed Domain Events
928
945
  if (isStatusChange) {
929
946
  const statusEvent: TaskStatusChangedEvent = {
930
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
947
+ id: `evt_${crypto.randomUUID()}`,
931
948
  name: 'task.status_changed',
932
949
  aggregateId: updated.id,
933
950
  aggregateType: 'Task',
@@ -952,7 +969,7 @@ export class CriticalPathEngine {
952
969
  }
953
970
  } else {
954
971
  const updateEvent: TaskUpdatedEvent = {
955
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
972
+ id: `evt_${crypto.randomUUID()}`,
956
973
  name: 'task.updated',
957
974
  aggregateId: updated.id,
958
975
  aggregateType: 'Task',
@@ -974,7 +991,7 @@ export class CriticalPathEngine {
974
991
  if (!wasBlocked && isNowBlocked) {
975
992
  const blockedReason = updated.blockedReason ?? null;
976
993
  const blockedEvent: TaskBlockedEvent = {
977
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
994
+ id: `evt_${crypto.randomUUID()}`,
978
995
  name: 'task.blocked',
979
996
  aggregateId: updated.id,
980
997
  aggregateType: 'Task',
@@ -997,7 +1014,7 @@ export class CriticalPathEngine {
997
1014
  });
998
1015
  } else if (wasBlocked && !isNowBlocked) {
999
1016
  const unblockedEvent: TaskUnblockedEvent = {
1000
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1017
+ id: `evt_${crypto.randomUUID()}`,
1001
1018
  name: 'task.unblocked',
1002
1019
  aggregateId: updated.id,
1003
1020
  aggregateType: 'Task',
@@ -1081,7 +1098,7 @@ export class CriticalPathEngine {
1081
1098
 
1082
1099
  const now = new Date().toISOString();
1083
1100
  const unblockedEvent: TaskUnblockedEvent = {
1084
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1101
+ id: `evt_${crypto.randomUUID()}`,
1085
1102
  name: 'task.unblocked',
1086
1103
  aggregateId: downstream.id,
1087
1104
  aggregateType: 'Task',
@@ -1127,7 +1144,7 @@ export class CriticalPathEngine {
1127
1144
  const now = new Date().toISOString();
1128
1145
 
1129
1146
  const event: TaskDeletedEvent = {
1130
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1147
+ id: `evt_${crypto.randomUUID()}`,
1131
1148
  name: 'task.deleted',
1132
1149
  aggregateId: id,
1133
1150
  aggregateType: 'Task',
@@ -1198,7 +1215,7 @@ export class CriticalPathEngine {
1198
1215
  const removed = await this.store.removeDependency(id);
1199
1216
  if (removed) {
1200
1217
  const event: TaskDependencyRemovedEvent = {
1201
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1218
+ id: `evt_${crypto.randomUUID()}`,
1202
1219
  name: 'dependency.removed',
1203
1220
  aggregateId: id,
1204
1221
  aggregateType: 'Dependency',
@@ -1229,7 +1246,7 @@ export class CriticalPathEngine {
1229
1246
  const now = new Date().toISOString();
1230
1247
 
1231
1248
  const event: TaskDependencyAddedEvent = {
1232
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1249
+ id: `evt_${crypto.randomUUID()}`,
1233
1250
  name: 'dependency.added',
1234
1251
  aggregateId: created.id,
1235
1252
  aggregateType: 'Dependency',
@@ -1357,7 +1374,7 @@ export class CriticalPathEngine {
1357
1374
  const now = new Date().toISOString();
1358
1375
 
1359
1376
  const event: TimeLoggedEvent = {
1360
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1377
+ id: `evt_${crypto.randomUUID()}`,
1361
1378
  name: 'time.logged',
1362
1379
  aggregateId: entry.taskId,
1363
1380
  aggregateType: 'Task',
@@ -1407,7 +1424,7 @@ export class CriticalPathEngine {
1407
1424
  const now = new Date().toISOString();
1408
1425
 
1409
1426
  const event: CommentAddedEvent = {
1410
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1427
+ id: `evt_${crypto.randomUUID()}`,
1411
1428
  name: 'comment.created',
1412
1429
  aggregateId: created.id,
1413
1430
  aggregateType: 'Comment',
@@ -1435,7 +1452,7 @@ export class CriticalPathEngine {
1435
1452
 
1436
1453
  const now = new Date().toISOString();
1437
1454
  const event: CommentUpdatedEvent = {
1438
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1455
+ id: `evt_${crypto.randomUUID()}`,
1439
1456
  name: 'comment.updated',
1440
1457
  aggregateId: updated.id,
1441
1458
  aggregateType: 'Comment',
@@ -1459,7 +1476,7 @@ export class CriticalPathEngine {
1459
1476
  if (deleted) {
1460
1477
  const now = new Date().toISOString();
1461
1478
  const event: CommentDeletedEvent = {
1462
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1479
+ id: `evt_${crypto.randomUUID()}`,
1463
1480
  name: 'comment.deleted',
1464
1481
  aggregateId: id,
1465
1482
  aggregateType: 'Comment',
@@ -1498,7 +1515,7 @@ export class CriticalPathEngine {
1498
1515
 
1499
1516
  const now = new Date().toISOString();
1500
1517
  const event: CommentReactionAddedEvent = {
1501
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1518
+ id: `evt_${crypto.randomUUID()}`,
1502
1519
  name: 'comment.reaction.added',
1503
1520
  aggregateId: commentId,
1504
1521
  aggregateType: 'Comment',
@@ -1538,7 +1555,7 @@ export class CriticalPathEngine {
1538
1555
 
1539
1556
  const now = new Date().toISOString();
1540
1557
  const event: CommentReactionRemovedEvent = {
1541
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1558
+ id: `evt_${crypto.randomUUID()}`,
1542
1559
  name: 'comment.reaction.removed',
1543
1560
  aggregateId: commentId,
1544
1561
  aggregateType: 'Comment',
@@ -1587,7 +1604,7 @@ export class CriticalPathEngine {
1587
1604
  const now = new Date().toISOString();
1588
1605
 
1589
1606
  const event: AttachmentCreatedEvent = {
1590
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1607
+ id: `evt_${crypto.randomUUID()}`,
1591
1608
  name: 'attachment.created',
1592
1609
  aggregateId: created.id,
1593
1610
  aggregateType: 'Attachment',
@@ -1619,7 +1636,7 @@ export class CriticalPathEngine {
1619
1636
  if (deleted) {
1620
1637
  const now = new Date().toISOString();
1621
1638
  const event: AttachmentDeletedEvent = {
1622
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1639
+ id: `evt_${crypto.randomUUID()}`,
1623
1640
  name: 'attachment.deleted',
1624
1641
  aggregateId: id,
1625
1642
  aggregateType: 'Attachment',
@@ -1774,7 +1791,7 @@ export class CriticalPathEngine {
1774
1791
  const now = new Date().toISOString();
1775
1792
 
1776
1793
  const event: TeamCreatedEvent = {
1777
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1794
+ id: `evt_${crypto.randomUUID()}`,
1778
1795
  name: 'team.created',
1779
1796
  aggregateId: created.id,
1780
1797
  aggregateType: 'Team',
@@ -1787,15 +1804,35 @@ export class CriticalPathEngine {
1787
1804
  }
1788
1805
 
1789
1806
  async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
1790
- if (!(await this.getTeam(id))) return null;
1807
+ const existing = await this.getTeam(id);
1808
+ if (!existing) return null;
1791
1809
  await this.requireWorkspaceAccess('workspace.manage');
1792
- return this.store.updateTeam(id, this.withoutTenant(updates));
1810
+ const updated = await this.store.updateTeam(id, this.withoutTenant(updates));
1811
+ if (updated) {
1812
+ await this.publishEvent<TeamUpdatedEvent>({
1813
+ name: 'team.updated',
1814
+ aggregateId: id,
1815
+ aggregateType: 'Team',
1816
+ payload: { team: updated, previous: existing }
1817
+ });
1818
+ }
1819
+ return updated;
1793
1820
  }
1794
1821
 
1795
1822
  async deleteTeam(id: string): Promise<boolean> {
1796
- if (!(await this.getTeam(id))) return false;
1823
+ const existing = await this.getTeam(id);
1824
+ if (!existing) return false;
1797
1825
  await this.requireWorkspaceAccess('workspace.manage');
1798
- return this.store.deleteTeam(id);
1826
+ const deleted = await this.store.deleteTeam(id);
1827
+ if (deleted) {
1828
+ await this.publishEvent<TeamDeletedEvent>({
1829
+ name: 'team.deleted',
1830
+ aggregateId: id,
1831
+ aggregateType: 'Team',
1832
+ payload: { teamId: id, name: existing.name, tenantId: existing.tenantId }
1833
+ });
1834
+ }
1835
+ return deleted;
1799
1836
  }
1800
1837
 
1801
1838
  // --- Containers ---
@@ -1816,7 +1853,7 @@ export class CriticalPathEngine {
1816
1853
  const now = new Date().toISOString();
1817
1854
 
1818
1855
  const event: ContainerCreatedEvent = {
1819
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1856
+ id: `evt_${crypto.randomUUID()}`,
1820
1857
  name: 'container.created',
1821
1858
  aggregateId: created.id,
1822
1859
  aggregateType: 'Container',
@@ -1832,7 +1869,16 @@ export class CriticalPathEngine {
1832
1869
  const existing = await this.getContainer(id);
1833
1870
  if (!existing) return null;
1834
1871
  await this.requireProjectAccess('plan.manage', existing.projectId);
1835
- return this.store.updateContainer(id, updates);
1872
+ const updated = await this.store.updateContainer(id, updates);
1873
+ if (updated) {
1874
+ await this.publishEvent<ContainerUpdatedEvent>({
1875
+ name: 'container.updated',
1876
+ aggregateId: id,
1877
+ aggregateType: 'Container',
1878
+ payload: { container: updated, previous: existing }
1879
+ });
1880
+ }
1881
+ return updated;
1836
1882
  }
1837
1883
 
1838
1884
  /** Deletes a container. Its tasks and nested containers are kept but no longer reference it. */
@@ -1844,7 +1890,16 @@ export class CriticalPathEngine {
1844
1890
  for (const child of (await this.store.getContainers(existing.projectId)).filter((c) => c.parentId === id)) {
1845
1891
  await this.store.updateContainer(child.id, { parentId: undefined });
1846
1892
  }
1847
- return this.store.deleteContainer(id);
1893
+ const deleted = await this.store.deleteContainer(id);
1894
+ if (deleted) {
1895
+ await this.publishEvent<ContainerDeletedEvent>({
1896
+ name: 'container.deleted',
1897
+ aggregateId: id,
1898
+ aggregateType: 'Container',
1899
+ payload: { containerId: id, projectId: existing.projectId, name: existing.name }
1900
+ });
1901
+ }
1902
+ return deleted;
1848
1903
  }
1849
1904
 
1850
1905
  /** Unsets a reference (e.g. `iterationId`) on every task in the project that points at `id`. */
@@ -1886,7 +1941,7 @@ export class CriticalPathEngine {
1886
1941
  const now = new Date().toISOString();
1887
1942
 
1888
1943
  const event: DeliverableCreatedEvent = {
1889
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1944
+ id: `evt_${crypto.randomUUID()}`,
1890
1945
  name: 'deliverable.created',
1891
1946
  aggregateId: created.id,
1892
1947
  aggregateType: 'Deliverable',
@@ -1919,7 +1974,7 @@ export class CriticalPathEngine {
1919
1974
 
1920
1975
  if (updates.status && updates.status !== existing.status) {
1921
1976
  const statusEvent: DeliverableStatusChangedEvent = {
1922
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1977
+ id: `evt_${crypto.randomUUID()}`,
1923
1978
  name: 'deliverable.status_changed',
1924
1979
  aggregateId: updated.id,
1925
1980
  aggregateType: 'Deliverable',
@@ -1934,7 +1989,7 @@ export class CriticalPathEngine {
1934
1989
  }
1935
1990
 
1936
1991
  const updateEvent: DeliverableUpdatedEvent = {
1937
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1992
+ id: `evt_${crypto.randomUUID()}`,
1938
1993
  name: 'deliverable.updated',
1939
1994
  aggregateId: updated.id,
1940
1995
  aggregateType: 'Deliverable',
@@ -1963,7 +2018,7 @@ export class CriticalPathEngine {
1963
2018
  if (deleted) {
1964
2019
  const now = new Date().toISOString();
1965
2020
  const event: DeliverableDeletedEvent = {
1966
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
2021
+ id: `evt_${crypto.randomUUID()}`,
1967
2022
  name: 'deliverable.deleted',
1968
2023
  aggregateId: id,
1969
2024
  aggregateType: 'Deliverable',
@@ -2044,10 +2099,16 @@ export class CriticalPathEngine {
2044
2099
  async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
2045
2100
  await this.requireProjectAccess('plan.manage', iteration.projectId);
2046
2101
  const created = await this.store.createIteration(iteration);
2102
+ await this.publishEvent<IterationCreatedEvent>({
2103
+ name: 'iteration.created',
2104
+ aggregateId: created.id,
2105
+ aggregateType: 'Iteration',
2106
+ payload: { iteration: created }
2107
+ });
2047
2108
  if (created.status === 'active') {
2048
2109
  const now = new Date().toISOString();
2049
2110
  const event: IterationStartedEvent = {
2050
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
2111
+ id: `evt_${crypto.randomUUID()}`,
2051
2112
  name: 'iteration.started',
2052
2113
  aggregateId: created.id,
2053
2114
  aggregateType: 'Iteration',
@@ -2066,10 +2127,16 @@ export class CriticalPathEngine {
2066
2127
 
2067
2128
  const updated = await this.store.updateIteration(id, updates);
2068
2129
  if (updated) {
2130
+ await this.publishEvent<IterationUpdatedEvent>({
2131
+ name: 'iteration.updated',
2132
+ aggregateId: id,
2133
+ aggregateType: 'Iteration',
2134
+ payload: { iteration: updated, previous: existing }
2135
+ });
2069
2136
  const now = new Date().toISOString();
2070
2137
  if (existing.status !== 'active' && updated.status === 'active') {
2071
2138
  const event: IterationStartedEvent = {
2072
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
2139
+ id: `evt_${crypto.randomUUID()}`,
2073
2140
  name: 'iteration.started',
2074
2141
  aggregateId: updated.id,
2075
2142
  aggregateType: 'Iteration',
@@ -2079,7 +2146,7 @@ export class CriticalPathEngine {
2079
2146
  await this.events.publish(event);
2080
2147
  } else if (existing.status !== 'completed' && updated.status === 'completed') {
2081
2148
  const event: IterationCompletedEvent = {
2082
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
2149
+ id: `evt_${crypto.randomUUID()}`,
2083
2150
  name: 'iteration.completed',
2084
2151
  aggregateId: updated.id,
2085
2152
  aggregateType: 'Iteration',
@@ -2098,7 +2165,16 @@ export class CriticalPathEngine {
2098
2165
  if (!existing) return false;
2099
2166
  await this.requireProjectAccess('plan.manage', existing.projectId);
2100
2167
  await this.clearTaskReferences(existing.projectId, 'iterationId', id);
2101
- return this.store.deleteIteration(id);
2168
+ const deleted = await this.store.deleteIteration(id);
2169
+ if (deleted) {
2170
+ await this.publishEvent<IterationDeletedEvent>({
2171
+ name: 'iteration.deleted',
2172
+ aggregateId: id,
2173
+ aggregateType: 'Iteration',
2174
+ payload: { iterationId: id, projectId: existing.projectId, name: existing.name }
2175
+ });
2176
+ }
2177
+ return deleted;
2102
2178
  }
2103
2179
 
2104
2180
  // --- Ladder of Abstraction & Critical Path Method ---
@@ -56,14 +56,14 @@ export class InMemoryFirestoreMock implements FirestoreDBInterface {
56
56
  return {
57
57
  exists: !!data,
58
58
  id: docId,
59
- data: () => data
59
+ data: () => structuredClone(data)
60
60
  };
61
61
  },
62
62
  async set(data: any, options?: { merge?: boolean }) {
63
63
  if (options?.merge && colMap.has(docId)) {
64
- colMap.set(docId, { ...colMap.get(docId), ...data });
64
+ colMap.set(docId, structuredClone({ ...colMap.get(docId), ...data }));
65
65
  } else {
66
- colMap.set(docId, data);
66
+ colMap.set(docId, structuredClone(data));
67
67
  }
68
68
  },
69
69
  async delete() {
@@ -74,7 +74,7 @@ export class InMemoryFirestoreMock implements FirestoreDBInterface {
74
74
  async get() {
75
75
  const docs = Array.from(colMap.entries()).map(([id, data]) => ({
76
76
  id,
77
- data: () => data
77
+ data: () => structuredClone(data)
78
78
  }));
79
79
  return { docs };
80
80
  },
@@ -88,7 +88,7 @@ export class InMemoryFirestoreMock implements FirestoreDBInterface {
88
88
  })
89
89
  .map(([id, data]) => ({
90
90
  id,
91
- data: () => data
91
+ data: () => structuredClone(data)
92
92
  }));
93
93
  return { docs };
94
94
  }
@@ -148,6 +148,10 @@ export interface StorageAdapter
148
148
  DependencyRepository,
149
149
  WebhookRepository {}
150
150
 
151
+ /**
152
+ * Map-backed store for development and tests. Reads and writes are deep-copied, so it behaves
153
+ * like a database: returned records can be mutated freely without affecting stored state.
154
+ */
151
155
  export class InMemoryStore implements StorageAdapter {
152
156
  private projects = new Map<string, Project>();
153
157
  private workflows = new Map<string, Workflow>();
@@ -163,6 +167,19 @@ export class InMemoryStore implements StorageAdapter {
163
167
  private dependencies = new Map<string, TaskDependency>();
164
168
  private webhooks = new Map<string, Webhook>();
165
169
 
170
+ constructor() {
171
+ // Hand out copies: callers mutating a returned record (or an input they keep using) must not
172
+ // change what is stored, matching the database-backed adapters. Internal calls between
173
+ // methods use the unwrapped instance, so each call clones only at the boundary.
174
+ return new Proxy(this, {
175
+ get(target, property, receiver) {
176
+ const value = Reflect.get(target, property, receiver);
177
+ if (typeof value !== 'function' || property === 'constructor') return value;
178
+ return async (...args: unknown[]) => structuredClone(await value.apply(target, structuredClone(args)));
179
+ }
180
+ });
181
+ }
182
+
166
183
  async getProjects(): Promise<Project[]> {
167
184
  return Array.from(this.projects.values());
168
185
  }
@@ -172,7 +189,7 @@ export class InMemoryStore implements StorageAdapter {
172
189
  }
173
190
 
174
191
  async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
175
- const id = `proj_${Math.random().toString(36).substring(2, 9)}`;
192
+ const id = `proj_${crypto.randomUUID()}`;
176
193
  const now = new Date().toISOString();
177
194
  const key = project.key || generateProjectKey(project.name);
178
195
  const newProject: Project = {
@@ -212,7 +229,7 @@ export class InMemoryStore implements StorageAdapter {
212
229
  }
213
230
 
214
231
  async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
215
- const id = `wf_${Math.random().toString(36).substring(2, 9)}`;
232
+ const id = `wf_${crypto.randomUUID()}`;
216
233
  const now = new Date().toISOString();
217
234
  const newWorkflow: Workflow = {
218
235
  ...workflow,
@@ -258,7 +275,7 @@ export class InMemoryStore implements StorageAdapter {
258
275
  }
259
276
 
260
277
  async createTask(task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<Task> {
261
- const id = `task_${Math.random().toString(36).substring(2, 9)}`;
278
+ const id = `task_${crypto.randomUUID()}`;
262
279
  const now = new Date().toISOString();
263
280
  const newTask: Task = {
264
281
  ...task,
@@ -296,7 +313,7 @@ export class InMemoryStore implements StorageAdapter {
296
313
  }
297
314
 
298
315
  async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
299
- const id = `team_${Math.random().toString(36).substring(2, 9)}`;
316
+ const id = `team_${crypto.randomUUID()}`;
300
317
  const now = new Date().toISOString();
301
318
  const newTeam: Team = { ...team, id, createdAt: now, updatedAt: now };
302
319
  this.teams.set(id, newTeam);
@@ -325,7 +342,7 @@ export class InMemoryStore implements StorageAdapter {
325
342
  }
326
343
 
327
344
  async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
328
- const id = `cnt_${Math.random().toString(36).substring(2, 9)}`;
345
+ const id = `cnt_${crypto.randomUUID()}`;
329
346
  const now = new Date().toISOString();
330
347
  const newContainer: TaskContainer = { ...container, id, createdAt: now, updatedAt: now };
331
348
  this.containers.set(id, newContainer);
@@ -354,7 +371,7 @@ export class InMemoryStore implements StorageAdapter {
354
371
  }
355
372
 
356
373
  async createDeliverable(deliverable: CreateDeliverableInput): Promise<Deliverable> {
357
- const id = (deliverable as any).id || `deliv_${Math.random().toString(36).substring(2, 9)}`;
374
+ const id = (deliverable as any).id || `deliv_${crypto.randomUUID()}`;
358
375
  const now = new Date().toISOString();
359
376
  const newDeliverable: Deliverable = {
360
377
  ...deliverable,
@@ -395,7 +412,7 @@ export class InMemoryStore implements StorageAdapter {
395
412
  }
396
413
 
397
414
  async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
398
- const id = `iter_${Math.random().toString(36).substring(2, 9)}`;
415
+ const id = `iter_${crypto.randomUUID()}`;
399
416
  const newIteration: Iteration = {
400
417
  ...iteration,
401
418
  id,
@@ -427,7 +444,7 @@ export class InMemoryStore implements StorageAdapter {
427
444
  }
428
445
 
429
446
  async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
430
- const id = `cmt_${Math.random().toString(36).substring(2, 9)}`;
447
+ const id = `cmt_${crypto.randomUUID()}`;
431
448
  const now = new Date().toISOString();
432
449
  const newComment: Comment = { ...comment, id, createdAt: now, updatedAt: now };
433
450
  this.comments.set(id, newComment);
@@ -501,7 +518,7 @@ export class InMemoryStore implements StorageAdapter {
501
518
  }
502
519
 
503
520
  async createAttachment(attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Attachment> {
504
- const id = `att_${Math.random().toString(36).substring(2, 9)}`;
521
+ const id = `att_${crypto.randomUUID()}`;
505
522
  const now = new Date().toISOString();
506
523
  const newAttachment: Attachment = {
507
524
  ...attachment,
@@ -532,7 +549,7 @@ export class InMemoryStore implements StorageAdapter {
532
549
  }
533
550
 
534
551
  async logActivity(activity: Omit<Activity, 'id' | 'createdAt'>): Promise<Activity> {
535
- const id = `act_${Math.random().toString(36).substring(2, 9)}`;
552
+ const id = `act_${crypto.randomUUID()}`;
536
553
  const newAct: Activity = { ...activity, id, createdAt: new Date().toISOString() };
537
554
  this.activities.unshift(newAct);
538
555
  return newAct;
@@ -544,7 +561,7 @@ export class InMemoryStore implements StorageAdapter {
544
561
  }
545
562
 
546
563
  async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string }): Promise<TimeEntry> {
547
- const id = `time_${Math.random().toString(36).substring(2, 9)}`;
564
+ const id = `time_${crypto.randomUUID()}`;
548
565
  const newEntry: TimeEntry = { ...entry, id, loggedAt: entry.loggedAt || new Date().toISOString() };
549
566
  this.timeEntries.set(id, newEntry);
550
567
  return newEntry;
@@ -566,7 +583,7 @@ export class InMemoryStore implements StorageAdapter {
566
583
  }
567
584
 
568
585
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
569
- const id = `dep_${Math.random().toString(36).substring(2, 9)}`;
586
+ const id = `dep_${crypto.randomUUID()}`;
570
587
  const newDep: TaskDependency = { ...dep, id };
571
588
  this.dependencies.set(id, newDep);
572
589
  return newDep;
@@ -586,7 +603,7 @@ export class InMemoryStore implements StorageAdapter {
586
603
  }
587
604
 
588
605
  async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
589
- const id = `wh_${Math.random().toString(36).substring(2, 9)}`;
606
+ const id = `wh_${crypto.randomUUID()}`;
590
607
  const newWh: Webhook = { ...webhook, id, createdAt: new Date().toISOString() };
591
608
  this.webhooks.set(id, newWh);
592
609
  return newWh;