@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.
- package/CHANGELOG.md +43 -0
- package/README.md +6 -0
- package/dist/domain/custom-fields.d.ts +7 -2
- package/dist/domain/custom-fields.d.ts.map +1 -1
- package/dist/domain/custom-fields.js +23 -1
- package/dist/domain/custom-fields.js.map +1 -1
- package/dist/domain/events.d.ts +8 -2
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js +1 -0
- package/dist/domain/events.js.map +1 -1
- package/dist/engine/cascade.test.d.ts +2 -0
- package/dist/engine/cascade.test.d.ts.map +1 -0
- package/dist/engine/cascade.test.js +99 -0
- package/dist/engine/cascade.test.js.map +1 -0
- package/dist/engine/index.d.ts +17 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +120 -27
- package/dist/engine/index.js.map +1 -1
- package/dist/plugins/index.d.ts +13 -3
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +44 -15
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/plugins.test.d.ts +2 -0
- package/dist/plugins/plugins.test.d.ts.map +1 -0
- package/dist/plugins/plugins.test.js +137 -0
- package/dist/plugins/plugins.test.js.map +1 -0
- package/dist/schemas/index.d.ts +96 -93
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/store/firebase.d.ts +8 -0
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +30 -8
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/index.d.ts +7 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +9 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +3 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +41 -24
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +51 -5
- package/dist/types/index.d.ts.map +1 -1
- package/dist/webhooks/webhooks.test.js +2 -1
- package/dist/webhooks/webhooks.test.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/custom-fields.ts +43 -2
- package/src/domain/events.ts +7 -0
- package/src/engine/cascade.test.ts +121 -0
- package/src/engine/index.ts +142 -30
- package/src/plugins/index.ts +51 -16
- package/src/plugins/plugins.test.ts +161 -0
- package/src/schemas/index.ts +2 -1
- package/src/store/firebase.ts +33 -8
- package/src/store/index.ts +16 -0
- package/src/store/sqlite.ts +56 -31
- package/src/types/index.ts +57 -5
- package/src/webhooks/webhooks.test.ts +2 -1
- package/tsconfig.tsbuildinfo +1 -1
package/dist/engine/index.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
|
447
|
-
|
|
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 (
|
|
538
|
-
const isValid = validateTransition(workflow || undefined, existing.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,
|
|
561
|
+
throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
|
|
541
562
|
}
|
|
542
563
|
}
|
|
543
564
|
// Validate custom field invariants
|
|
544
|
-
if (
|
|
545
|
-
validateCustomFieldValues(project
|
|
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
|
-
|
|
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 ---
|