@critical-path/core 0.27.0 → 0.29.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 (60) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -0
  3. package/dist/domain/custom-fields.d.ts +7 -2
  4. package/dist/domain/custom-fields.d.ts.map +1 -1
  5. package/dist/domain/custom-fields.js +23 -1
  6. package/dist/domain/custom-fields.js.map +1 -1
  7. package/dist/domain/events.d.ts +8 -2
  8. package/dist/domain/events.d.ts.map +1 -1
  9. package/dist/domain/events.js +1 -0
  10. package/dist/domain/events.js.map +1 -1
  11. package/dist/engine/cascade.test.d.ts +2 -0
  12. package/dist/engine/cascade.test.d.ts.map +1 -0
  13. package/dist/engine/cascade.test.js +99 -0
  14. package/dist/engine/cascade.test.js.map +1 -0
  15. package/dist/engine/index.d.ts +17 -1
  16. package/dist/engine/index.d.ts.map +1 -1
  17. package/dist/engine/index.js +120 -27
  18. package/dist/engine/index.js.map +1 -1
  19. package/dist/plugins/index.d.ts +13 -3
  20. package/dist/plugins/index.d.ts.map +1 -1
  21. package/dist/plugins/index.js +44 -15
  22. package/dist/plugins/index.js.map +1 -1
  23. package/dist/plugins/plugins.test.d.ts +2 -0
  24. package/dist/plugins/plugins.test.d.ts.map +1 -0
  25. package/dist/plugins/plugins.test.js +137 -0
  26. package/dist/plugins/plugins.test.js.map +1 -0
  27. package/dist/schemas/index.d.ts +96 -93
  28. package/dist/schemas/index.d.ts.map +1 -1
  29. package/dist/schemas/index.js +2 -1
  30. package/dist/schemas/index.js.map +1 -1
  31. package/dist/store/firebase.d.ts +8 -0
  32. package/dist/store/firebase.d.ts.map +1 -1
  33. package/dist/store/firebase.js +30 -8
  34. package/dist/store/firebase.js.map +1 -1
  35. package/dist/store/index.d.ts +7 -0
  36. package/dist/store/index.d.ts.map +1 -1
  37. package/dist/store/index.js +9 -0
  38. package/dist/store/index.js.map +1 -1
  39. package/dist/store/sqlite.d.ts +3 -0
  40. package/dist/store/sqlite.d.ts.map +1 -1
  41. package/dist/store/sqlite.js +41 -24
  42. package/dist/store/sqlite.js.map +1 -1
  43. package/dist/types/index.d.ts +51 -5
  44. package/dist/types/index.d.ts.map +1 -1
  45. package/dist/webhooks/webhooks.test.js +2 -1
  46. package/dist/webhooks/webhooks.test.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/domain/custom-fields.ts +43 -2
  49. package/src/domain/events.ts +7 -0
  50. package/src/engine/cascade.test.ts +121 -0
  51. package/src/engine/index.ts +142 -30
  52. package/src/plugins/index.ts +51 -16
  53. package/src/plugins/plugins.test.ts +161 -0
  54. package/src/schemas/index.ts +2 -1
  55. package/src/store/firebase.ts +33 -8
  56. package/src/store/index.ts +16 -0
  57. package/src/store/sqlite.ts +56 -31
  58. package/src/types/index.ts +57 -5
  59. package/src/webhooks/webhooks.test.ts +2 -1
  60. package/tsconfig.tsbuildinfo +1 -1
@@ -5,7 +5,7 @@ import { validateTransition, getAllowedNextStatuses, getAllowedPreviousStatuses,
5
5
  import { extractMentions } from '../utils/mentions.js';
6
6
  import { DomainEventBus } from '../domain/events.js';
7
7
  import { validateAttachmentUrl } from '../domain/entities.js';
8
- import { validateCustomFieldValues } from '../domain/custom-fields.js';
8
+ import { validateCustomFieldValues, validateCustomFieldDefinitions } from '../domain/custom-fields.js';
9
9
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
10
10
  import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
11
11
  import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
@@ -45,9 +45,9 @@ export class CriticalPathEngine {
45
45
  this.plugins.register(plugin);
46
46
  }
47
47
  }
48
- if (config.initialData) {
49
- this.ready = this.seedInitialData(config.initialData);
50
- }
48
+ this.ready = this.initialize(config);
49
+ // Callers that never await `ready` should not crash on an unhandled rejection; awaiting it still throws.
50
+ this.ready.catch(() => { });
51
51
  }
52
52
  /**
53
53
  * Returns a view of this engine that attributes every mutation to `actor`: activity log
@@ -160,6 +160,14 @@ export class CriticalPathEngine {
160
160
  const { tenantId: _ignored, ...rest } = updates;
161
161
  return rest;
162
162
  }
163
+ async initialize(config) {
164
+ if (config.initialData) {
165
+ await this.seedInitialData(config.initialData);
166
+ }
167
+ for (const plugin of this.plugins.getPlugins()) {
168
+ await plugin.init?.(this);
169
+ }
170
+ }
163
171
  async seedInitialData(data) {
164
172
  if (data.workflows) {
165
173
  for (const wf of data.workflows) {
@@ -328,6 +336,7 @@ export class CriticalPathEngine {
328
336
  }
329
337
  async createProject(project) {
330
338
  await this.requireWorkspaceAccess('project.create');
339
+ validateCustomFieldDefinitions(project.customFieldDefinitions, this.plugins.getCustomFieldTypes());
331
340
  // Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
332
341
  const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
333
342
  // The creator administers the project they create.
@@ -360,6 +369,7 @@ export class CriticalPathEngine {
360
369
  await this.requireProjectAccess('project.update', id);
361
370
  if ('members' in updates)
362
371
  await this.requireProjectAccess('project.manage_members', id);
372
+ validateCustomFieldDefinitions(updates.customFieldDefinitions, this.plugins.getCustomFieldTypes());
363
373
  updates = this.withoutTenant(updates);
364
374
  const updated = await this.store.updateProject(id, updates);
365
375
  if (updated) {
@@ -394,12 +404,19 @@ export class CriticalPathEngine {
394
404
  // Deleting the project implies deleting its tasks; skip per-task checks for the cascade.
395
405
  const cascade = this.elevated();
396
406
  const tasks = await this.store.getTasks(id);
397
- const deletedTaskIds = [];
398
407
  for (const task of tasks) {
399
- if (await cascade.deleteTask(task.id)) {
400
- deletedTaskIds.push(task.id);
401
- }
402
- }
408
+ // Subtasks may already be gone with their parent; deleteTask then returns false.
409
+ await cascade.deleteTask(task.id);
410
+ }
411
+ const deletedTaskIds = (await Promise.all(tasks.map(async (t) => ((await this.store.getTask(t.id)) ? null : t.id)))).filter((taskId) => taskId !== null);
412
+ for (const container of await this.store.getContainers(id))
413
+ await this.store.deleteContainer(container.id);
414
+ for (const iteration of await this.store.getIterations(id))
415
+ await this.store.deleteIteration(iteration.id);
416
+ for (const deliverable of await this.store.getDeliverables(id))
417
+ await cascade.deleteDeliverable(deliverable.id);
418
+ for (const attachment of await this.store.getAttachments({ projectId: id }))
419
+ await cascade.deleteAttachment(attachment.id);
403
420
  const deleted = await this.store.deleteProject(id);
404
421
  if (deleted) {
405
422
  const now = new Date().toISOString();
@@ -440,13 +457,14 @@ export class CriticalPathEngine {
440
457
  taskInput = { ...taskInput, reporterId: this.actor.userId };
441
458
  }
442
459
  const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
443
- const projectId = processedInput.projectId || taskInput.projectId;
460
+ if (processedInput.projectId && processedInput.projectId !== taskInput.projectId) {
461
+ throw new ValidationError('Plugins cannot move a task to another project in beforeTaskCreate.');
462
+ }
463
+ const projectId = taskInput.projectId;
444
464
  const project = await this.store.getProject(projectId);
445
465
  const workflow = await this.resolveProjectWorkflow(projectId);
446
- // Validate custom field domain invariants
447
- if (project?.customFieldDefinitions && (processedInput.customFields || taskInput.customFields)) {
448
- validateCustomFieldValues(project.customFieldDefinitions, processedInput.customFields || taskInput.customFields);
449
- }
466
+ // Validate custom fields on the final (post-plugin) input, including required fields when none are given
467
+ validateCustomFieldValues(project?.customFieldDefinitions, processedInput.customFields ?? taskInput.customFields, this.plugins.getCustomFieldTypes());
450
468
  const defaultStatus = workflow?.defaultStatusKey || 'todo';
451
469
  const initialStatus = processedInput.status || taskInput.status || defaultStatus;
452
470
  const now = new Date().toISOString();
@@ -533,21 +551,20 @@ export class CriticalPathEngine {
533
551
  const taskUpdates = updates;
534
552
  const project = await this.store.getProject(existing.projectId);
535
553
  const workflow = await this.resolveProjectWorkflow(existing.projectId);
554
+ // Plugins run first; their output is then validated like caller input. Identity and
555
+ // ownership fields are never changed by hooks.
556
+ const { id: _id, projectId: _projectId, createdAt: _createdAt, ...processedUpdates } = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
536
557
  // Validate workflow transition invariant
537
- if (taskUpdates.status && taskUpdates.status !== existing.status) {
538
- const isValid = validateTransition(workflow || undefined, existing.status, taskUpdates.status);
558
+ if (processedUpdates.status && processedUpdates.status !== existing.status) {
559
+ const isValid = validateTransition(workflow || undefined, existing.status, processedUpdates.status);
539
560
  if (!isValid) {
540
- throw new WorkflowValidationError(existing.status, taskUpdates.status, workflow?.id);
561
+ throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
541
562
  }
542
563
  }
543
564
  // Validate custom field invariants
544
- if (project?.customFieldDefinitions && taskUpdates.customFields) {
545
- validateCustomFieldValues(project.customFieldDefinitions, {
546
- ...existing.customFields,
547
- ...taskUpdates.customFields
548
- });
565
+ if (processedUpdates.customFields) {
566
+ validateCustomFieldValues(project?.customFieldDefinitions, { ...existing.customFields, ...processedUpdates.customFields }, this.plugins.getCustomFieldTypes());
549
567
  }
550
- const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
551
568
  const now = new Date().toISOString();
552
569
  const existingStatusDef = resolveStatusDefinition(existing.status, project?.statusDefinitions || workflow?.statuses);
553
570
  const newStatusDef = processedUpdates.status
@@ -864,15 +881,21 @@ export class CriticalPathEngine {
864
881
  console.error(`[CriticalPathEngine] Error during auto-unblocking for task ${completedTask.id}:`, err);
865
882
  }
866
883
  }
867
- async deleteTask(id) {
884
+ /**
885
+ * Deletes a task and everything that belongs to it: subtasks (or, with `subtasks: 'detach'`,
886
+ * their parent link), dependencies, comments, attachments (including stored files) and time
887
+ * entries. The activity log is kept as an audit trail.
888
+ */
889
+ async deleteTask(id, options = {}) {
868
890
  const existing = await this.getTask(id);
869
891
  if (!existing)
870
892
  return false;
871
893
  await this.requireProjectAccess('task.delete', existing.projectId);
872
- await this.plugins.runBeforeTaskDelete(id);
894
+ await this.plugins.runBeforeTaskDelete(id, existing);
895
+ await this.deleteTaskChildren(existing, options.subtasks ?? 'delete');
873
896
  const deleted = await this.store.deleteTask(id);
874
897
  if (deleted) {
875
- await this.plugins.runAfterTaskDelete(id);
898
+ await this.plugins.runAfterTaskDelete(id, existing);
876
899
  const now = new Date().toISOString();
877
900
  const event = {
878
901
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -897,6 +920,61 @@ export class CriticalPathEngine {
897
920
  }
898
921
  return deleted;
899
922
  }
923
+ /** Removes records owned by a task; the task's deletion was already authorized. */
924
+ async deleteTaskChildren(task, subtasks) {
925
+ const cascade = this.elevated();
926
+ const children = (await this.store.getTasks(task.projectId)).filter((t) => t.parentId === task.id);
927
+ for (const child of children) {
928
+ if (subtasks === 'delete') {
929
+ await cascade.deleteTask(child.id, { subtasks });
930
+ }
931
+ else {
932
+ await this.store.updateTask(child.id, { parentId: undefined });
933
+ }
934
+ }
935
+ for (const dependency of await this.store.getDependencies(task.id)) {
936
+ await cascade.removeDependency(dependency.id);
937
+ }
938
+ const comments = await this.store.getComments(task.id);
939
+ const attachments = [
940
+ ...(await this.store.getAttachments({ taskId: task.id })),
941
+ ...(await Promise.all(comments.map((c) => this.store.getAttachments({ commentId: c.id })))).flat()
942
+ ];
943
+ for (const attachmentId of new Set(attachments.map((a) => a.id))) {
944
+ await cascade.deleteAttachment(attachmentId);
945
+ }
946
+ for (const comment of comments) {
947
+ await cascade.deleteComment(comment.id);
948
+ }
949
+ for (const entry of await this.store.getTimeEntries(task.id)) {
950
+ await this.store.deleteTimeEntry(entry.id);
951
+ }
952
+ }
953
+ /** Removes a dependency between two tasks. Requires `task.update` on the dependent task's project. */
954
+ async removeDependency(id) {
955
+ const dependency = await this.store.getDependency(id);
956
+ if (!dependency)
957
+ return false;
958
+ if (this.enforcing) {
959
+ const task = await this.getTask(dependency.taskId);
960
+ if (!task)
961
+ return false;
962
+ await this.requireProjectAccess('task.update', task.projectId);
963
+ }
964
+ const removed = await this.store.removeDependency(id);
965
+ if (removed) {
966
+ const event = {
967
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
968
+ name: 'dependency.removed',
969
+ aggregateId: id,
970
+ aggregateType: 'Dependency',
971
+ occurredAt: new Date().toISOString(),
972
+ payload: { dependency }
973
+ };
974
+ await this.events.publish(event);
975
+ }
976
+ return removed;
977
+ }
900
978
  // --- Task Dependencies & Graph ---
901
979
  async addDependency(dep) {
902
980
  if (this.enforcing) {
@@ -1419,13 +1497,25 @@ export class CriticalPathEngine {
1419
1497
  await this.requireProjectAccess('plan.manage', existing.projectId);
1420
1498
  return this.store.updateContainer(id, updates);
1421
1499
  }
1500
+ /** Deletes a container. Its tasks and nested containers are kept but no longer reference it. */
1422
1501
  async deleteContainer(id) {
1423
1502
  const existing = await this.getContainer(id);
1424
1503
  if (!existing)
1425
1504
  return false;
1426
1505
  await this.requireProjectAccess('plan.manage', existing.projectId);
1506
+ await this.clearTaskReferences(existing.projectId, 'containerId', id);
1507
+ for (const child of (await this.store.getContainers(existing.projectId)).filter((c) => c.parentId === id)) {
1508
+ await this.store.updateContainer(child.id, { parentId: undefined });
1509
+ }
1427
1510
  return this.store.deleteContainer(id);
1428
1511
  }
1512
+ /** Unsets a reference (e.g. `iterationId`) on every task in the project that points at `id`. */
1513
+ async clearTaskReferences(projectId, field, id) {
1514
+ for (const task of await this.store.getTasks(projectId)) {
1515
+ if (task[field] === id)
1516
+ await this.store.updateTask(task.id, { [field]: undefined });
1517
+ }
1518
+ }
1429
1519
  // --- Deliverables ---
1430
1520
  async getDeliverables(projectId) {
1431
1521
  if (!(await this.canReadProject(projectId)))
@@ -1442,7 +1532,7 @@ export class CriticalPathEngine {
1442
1532
  await this.requireProjectAccess('plan.manage', input.projectId);
1443
1533
  const project = await this.store.getProject(input.projectId);
1444
1534
  if (project?.customFieldDefinitions && input.customFields) {
1445
- validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
1535
+ validateCustomFieldValues(project.customFieldDefinitions, input.customFields, this.plugins.getCustomFieldTypes());
1446
1536
  }
1447
1537
  const created = await this.store.createDeliverable({
1448
1538
  ...input,
@@ -1517,6 +1607,7 @@ export class CriticalPathEngine {
1517
1607
  if (!existing)
1518
1608
  return false;
1519
1609
  await this.requireProjectAccess('plan.manage', existing.projectId);
1610
+ await this.clearTaskReferences(existing.projectId, 'deliverableId', id);
1520
1611
  const deleted = await this.store.deleteDeliverable(id);
1521
1612
  if (deleted) {
1522
1613
  const now = new Date().toISOString();
@@ -1644,11 +1735,13 @@ export class CriticalPathEngine {
1644
1735
  }
1645
1736
  return updated;
1646
1737
  }
1738
+ /** Deletes an iteration. Its tasks are kept and moved back to the backlog (no iteration). */
1647
1739
  async deleteIteration(id) {
1648
1740
  const existing = await this.getIteration(id);
1649
1741
  if (!existing)
1650
1742
  return false;
1651
1743
  await this.requireProjectAccess('plan.manage', existing.projectId);
1744
+ await this.clearTaskReferences(existing.projectId, 'iterationId', id);
1652
1745
  return this.store.deleteIteration(id);
1653
1746
  }
1654
1747
  // --- Ladder of Abstraction & Critical Path Method ---