@critical-path/core 0.21.0 → 0.22.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 +34 -0
  2. package/README.md +4 -0
  3. package/dist/domain/ddd.test.js +12 -0
  4. package/dist/domain/ddd.test.js.map +1 -1
  5. package/dist/domain/entities.d.ts.map +1 -1
  6. package/dist/domain/entities.js +3 -2
  7. package/dist/domain/entities.js.map +1 -1
  8. package/dist/domain/errors.d.ts +13 -0
  9. package/dist/domain/errors.d.ts.map +1 -0
  10. package/dist/domain/errors.js +19 -0
  11. package/dist/domain/errors.js.map +1 -0
  12. package/dist/domain/events.d.ts +9 -1
  13. package/dist/domain/events.d.ts.map +1 -1
  14. package/dist/domain/events.js.map +1 -1
  15. package/dist/domain/index.d.ts +1 -0
  16. package/dist/domain/index.d.ts.map +1 -1
  17. package/dist/domain/index.js +1 -0
  18. package/dist/domain/index.js.map +1 -1
  19. package/dist/engine/index.d.ts +11 -0
  20. package/dist/engine/index.d.ts.map +1 -1
  21. package/dist/engine/index.js +72 -10
  22. package/dist/engine/index.js.map +1 -1
  23. package/dist/index.test.js +20 -0
  24. package/dist/index.test.js.map +1 -1
  25. package/dist/store/sqlite.d.ts.map +1 -1
  26. package/dist/store/sqlite.js +5 -1
  27. package/dist/store/sqlite.js.map +1 -1
  28. package/dist/store/sqlite.test.js +20 -0
  29. package/dist/store/sqlite.test.js.map +1 -1
  30. package/dist/types/index.d.ts +1 -1
  31. package/dist/types/index.d.ts.map +1 -1
  32. package/dist/utils/fractional-index.d.ts.map +1 -1
  33. package/dist/utils/fractional-index.js +22 -26
  34. package/dist/utils/fractional-index.js.map +1 -1
  35. package/dist/utils/fractional-index.test.js +31 -0
  36. package/dist/utils/fractional-index.test.js.map +1 -1
  37. package/package.json +1 -5
  38. package/src/domain/ddd.test.ts +22 -0
  39. package/src/domain/entities.ts +3 -2
  40. package/src/domain/errors.ts +19 -0
  41. package/src/domain/events.ts +6 -0
  42. package/src/domain/index.ts +1 -0
  43. package/src/engine/index.ts +79 -11
  44. package/src/index.test.ts +24 -0
  45. package/src/store/sqlite.test.ts +22 -0
  46. package/src/store/sqlite.ts +7 -1
  47. package/src/types/index.ts +1 -0
  48. package/src/utils/fractional-index.test.ts +37 -0
  49. package/src/utils/fractional-index.ts +20 -26
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Thrown when input fails a domain rule. Route handlers map it to HTTP 400.
3
+ */
4
+ export class ValidationError extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = 'ValidationError';
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Thrown when a referenced entity does not exist. Route handlers map it to HTTP 404.
13
+ */
14
+ export class NotFoundError extends Error {
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = 'NotFoundError';
18
+ }
19
+ }
@@ -134,6 +134,11 @@ export interface ProjectUpdatedEvent extends DomainEvent<{ project: Project; pre
134
134
  readonly aggregateType: 'Project';
135
135
  }
136
136
 
137
+ export interface ProjectDeletedEvent extends DomainEvent<{ projectId: string; name: string; deletedTaskIds: string[] }> {
138
+ readonly name: 'project.deleted';
139
+ readonly aggregateType: 'Project';
140
+ }
141
+
137
142
  export interface WorkflowCreatedEvent extends DomainEvent<{ workflow: Workflow }> {
138
143
  readonly name: 'workflow.created';
139
144
  readonly aggregateType: 'Workflow';
@@ -207,6 +212,7 @@ export type CriticalPathDomainEvent =
207
212
  | TaskDependencyAddedEvent
208
213
  | ProjectCreatedEvent
209
214
  | ProjectUpdatedEvent
215
+ | ProjectDeletedEvent
210
216
  | WorkflowCreatedEvent
211
217
  | WorkflowUpdatedEvent
212
218
  | WorkflowDeletedEvent
@@ -1,6 +1,7 @@
1
1
  export * from './events.js';
2
2
  export * from './custom-fields.js';
3
3
  export * from './graph.js';
4
+ export * from './errors.js';
4
5
  export * from './entities.js';
5
6
  export * from './cpm.js';
6
7
  export * from './ladder.js';
@@ -56,6 +56,7 @@ import {
56
56
  TaskDeletedEvent,
57
57
  ProjectCreatedEvent,
58
58
  ProjectUpdatedEvent,
59
+ ProjectDeletedEvent,
59
60
  WorkflowCreatedEvent,
60
61
  WorkflowUpdatedEvent,
61
62
  WorkflowDeletedEvent,
@@ -80,6 +81,7 @@ import {
80
81
  import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
81
82
  import { validateCustomFieldValues } from '../domain/custom-fields.js';
82
83
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
84
+ import { ValidationError, NotFoundError } from '../domain/errors.js';
83
85
  import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
84
86
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
85
87
  import {
@@ -340,6 +342,46 @@ export class CriticalPathEngine {
340
342
  return updated;
341
343
  }
342
344
 
345
+ /**
346
+ * Deletes a project and its tasks. Each task goes through `deleteTask`, so plugin
347
+ * hooks run and `task.deleted` events fire before `project.deleted` is published.
348
+ */
349
+ async deleteProject(id: string): Promise<boolean> {
350
+ const existing = await this.store.getProject(id);
351
+ if (!existing) return false;
352
+
353
+ const tasks = await this.store.getTasks(id);
354
+ const deletedTaskIds: string[] = [];
355
+ for (const task of tasks) {
356
+ if (await this.deleteTask(task.id)) {
357
+ deletedTaskIds.push(task.id);
358
+ }
359
+ }
360
+
361
+ const deleted = await this.store.deleteProject(id);
362
+ if (deleted) {
363
+ const now = new Date().toISOString();
364
+ const event: ProjectDeletedEvent = {
365
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
366
+ name: 'project.deleted',
367
+ aggregateId: id,
368
+ aggregateType: 'Project',
369
+ occurredAt: now,
370
+ payload: { projectId: id, name: existing.name, deletedTaskIds }
371
+ };
372
+ await this.events.publish(event);
373
+
374
+ await this.store.logActivity({
375
+ projectId: id,
376
+ actorId: 'system',
377
+ action: 'project.deleted',
378
+ details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
379
+ });
380
+ this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
381
+ }
382
+ return deleted;
383
+ }
384
+
343
385
  // --- Tasks ---
344
386
  async getTasks(projectId?: string): Promise<Task[]> {
345
387
  return this.store.getTasks(projectId);
@@ -916,14 +958,9 @@ export class CriticalPathEngine {
916
958
 
917
959
  // --- Task Dependencies & Graph ---
918
960
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
919
- const existingDependencies = await this.store.getDependencies(dep.taskId);
920
- const allProjectDeps = [
921
- ...existingDependencies,
922
- ...(await this.store.getDependencies(dep.dependsOnTaskId))
923
- ];
924
-
925
961
  // Enforce Directed Acyclic Graph (DAG) Invariant
926
- const cycleCheck = detectDependencyCycle(allProjectDeps, dep);
962
+ const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
963
+ const cycleCheck = detectDependencyCycle(reachableDeps, dep);
927
964
  if (cycleCheck.hasCycle) {
928
965
  throw new CircularDependencyError(dep.taskId, dep.dependsOnTaskId, cycleCheck.cyclePath);
929
966
  }
@@ -944,6 +981,37 @@ export class CriticalPathEngine {
944
981
  return created;
945
982
  }
946
983
 
984
+ /**
985
+ * Collects every dependency edge reachable upstream from `startTaskId`. A new edge
986
+ * `taskId -> dependsOnTaskId` creates a cycle exactly when `taskId` is reachable from
987
+ * `dependsOnTaskId`, so this is the full set of edges the cycle check needs.
988
+ */
989
+ private async collectUpstreamDependencies(startTaskId: string): Promise<TaskDependency[]> {
990
+ const edges = new Map<string, TaskDependency>();
991
+ const visited = new Set<string>();
992
+ let frontier = [startTaskId];
993
+
994
+ while (frontier.length > 0) {
995
+ const next: string[] = [];
996
+ const results = await Promise.all(frontier.map((id) => this.store.getDependencies(id)));
997
+ frontier.forEach((id) => visited.add(id));
998
+
999
+ for (const deps of results) {
1000
+ for (const d of deps) {
1001
+ // getDependencies returns edges in both directions; follow only outgoing ones.
1002
+ if (!visited.has(d.taskId)) continue;
1003
+ edges.set(d.id, d);
1004
+ if (!visited.has(d.dependsOnTaskId) && !next.includes(d.dependsOnTaskId)) {
1005
+ next.push(d.dependsOnTaskId);
1006
+ }
1007
+ }
1008
+ }
1009
+ frontier = next;
1010
+ }
1011
+
1012
+ return Array.from(edges.values());
1013
+ }
1014
+
947
1015
  async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
948
1016
  const dependencies = await this.store.getDependencies(taskId);
949
1017
  const upstreamTaskIds = new Set<string>();
@@ -996,8 +1064,8 @@ export class CriticalPathEngine {
996
1064
 
997
1065
  // --- Time Tracking ---
998
1066
  async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt' | 'userId'> & { userId?: string; loggedAt?: string }): Promise<TimeEntry> {
999
- if (entry.hours <= 0) {
1000
- throw new Error('Logged hours must be a positive number.');
1067
+ if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
1068
+ throw new ValidationError('Logged hours must be a positive number.');
1001
1069
  }
1002
1070
 
1003
1071
  const task = await this.store.getTask(entry.taskId);
@@ -1288,7 +1356,7 @@ export class CriticalPathEngine {
1288
1356
  async readAttachmentText(id: string): Promise<string> {
1289
1357
  const attachment = await this.getAttachment(id);
1290
1358
  if (!attachment) {
1291
- throw new Error(`Attachment with id "${id}" not found.`);
1359
+ throw new NotFoundError(`Attachment with id "${id}" not found.`);
1292
1360
  }
1293
1361
 
1294
1362
  if (attachment.storageKey && this.fileStorage && typeof this.fileStorage.download === 'function') {
@@ -1661,7 +1729,7 @@ export class CriticalPathEngine {
1661
1729
  ): Promise<TimelineLadder> {
1662
1730
  const project = await this.store.getProject(projectId);
1663
1731
  if (!project) {
1664
- throw new Error(`Project with ID "${projectId}" not found.`);
1732
+ throw new NotFoundError(`Project with ID "${projectId}" not found.`);
1665
1733
  }
1666
1734
 
1667
1735
  let tasks = await this.store.getTasks(projectId);
package/src/index.test.ts CHANGED
@@ -3,6 +3,30 @@ import { CriticalPathEngine, deriveTaskLifecycleState, DEFAULT_VFX_WORKFLOW } fr
3
3
  import type { CriticalPathPlugin } from './types/index.js';
4
4
 
5
5
  describe('CriticalPathEngine Core Tests', () => {
6
+ it('deletes a project with its tasks and publishes deletion events', async () => {
7
+ const engine = new CriticalPathEngine();
8
+ const proj = await engine.createProject({ key: 'DEL', name: 'Doomed Project' });
9
+ const keep = await engine.createProject({ key: 'KEEP', name: 'Survivor' });
10
+ const t1 = await engine.createTask({ projectId: proj.id, title: 'One' });
11
+ const t2 = await engine.createTask({ projectId: proj.id, title: 'Two' });
12
+ const other = await engine.createTask({ projectId: keep.id, title: 'Other' });
13
+
14
+ const published: string[] = [];
15
+ engine.events.subscribe('*', (event) => {
16
+ published.push(event.name);
17
+ });
18
+
19
+ expect(await engine.deleteProject(proj.id)).toBe(true);
20
+ expect(await engine.getProject(proj.id)).toBeNull();
21
+ expect(await engine.getTask(t1.id)).toBeNull();
22
+ expect(await engine.getTask(t2.id)).toBeNull();
23
+ expect(await engine.getTask(other.id)).not.toBeNull();
24
+ expect(published.filter((n) => n === 'task.deleted')).toHaveLength(2);
25
+ expect(published.at(-1)).toBe('project.deleted');
26
+
27
+ expect(await engine.deleteProject(proj.id)).toBe(false);
28
+ });
29
+
6
30
  it('creates projects and tasks with activity logs', async () => {
7
31
  const engine = new CriticalPathEngine();
8
32
 
@@ -1,4 +1,7 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
2
5
  import { SQLiteStore } from './sqlite.js';
3
6
 
4
7
  describe('SQLiteStore', () => {
@@ -194,3 +197,22 @@ describe('SQLiteStore', () => {
194
197
  expect(remaining).toHaveLength(0);
195
198
  });
196
199
  });
200
+
201
+ describe('SQLiteStore outside the vitest runtime', () => {
202
+ const distEntry = fileURLToPath(new URL('../../dist/index.js', import.meta.url));
203
+
204
+ // vitest injects `require` into ESM modules, which once hid an `eval("require(...)")` failure
205
+ // that broke `new SQLiteStore({ filename })` in plain Node. Run the built package in a real Node process.
206
+ it.skipIf(!existsSync(distEntry))('constructs from a filename in a plain Node ESM process', () => {
207
+ const script = `
208
+ const { SQLiteStore } = await import(${JSON.stringify(pathToFileURL(distEntry).href)});
209
+ const store = new SQLiteStore({ filename: ':memory:' });
210
+ const project = await store.createProject({ key: 'SMOKE', name: 'Smoke' });
211
+ process.stdout.write(project.key);
212
+ `;
213
+ const output = execFileSync(process.execPath, ['--input-type=module', '--no-warnings', '-e', script], {
214
+ encoding: 'utf8'
215
+ });
216
+ expect(output).toBe('SMOKE');
217
+ });
218
+ });
@@ -41,7 +41,13 @@ export class SQLiteStore implements StorageAdapter {
41
41
  throw new Error('SQLiteStore is only available in Node.js environments.');
42
42
  }
43
43
  try {
44
- const nodeSqlite = eval("require('node:sqlite')");
44
+ // `require` is undefined in ESM, and a static import would break browser bundles of core.
45
+ const nodeSqlite = (globalThis as any).process?.getBuiltinModule?.('node:sqlite') as
46
+ | typeof import('node:sqlite')
47
+ | undefined;
48
+ if (!nodeSqlite) {
49
+ throw new Error('process.getBuiltinModule is unavailable (requires Node.js 22.3+)');
50
+ }
45
51
  this.db = new nodeSqlite.DatabaseSync(config.filename || ':memory:');
46
52
  } catch (err: any) {
47
53
  throw new Error(`Failed to load node:sqlite module: ${err.message}`);
@@ -409,6 +409,7 @@ export interface Webhook {
409
409
  export type WebhookEvent =
410
410
  | 'project.created'
411
411
  | 'project.updated'
412
+ | 'project.deleted'
412
413
  | 'task.created'
413
414
  | 'task.updated'
414
415
  | 'task.deleted'
@@ -120,6 +120,43 @@ describe('fractional-index', () => {
120
120
  current = next;
121
121
  }
122
122
  });
123
+
124
+ it('handles repeated insertions directly after the same lower bound', () => {
125
+ const lower = 'a0';
126
+ let upper = 'a1';
127
+
128
+ // Repeatedly drop a card just below the first card
129
+ for (let i = 0; i < 50; i++) {
130
+ const next = generateKeyBetween(lower, upper);
131
+ expect(lower < next).toBe(true);
132
+ expect(next < upper).toBe(true);
133
+ upper = next;
134
+ }
135
+ });
136
+
137
+ it('keeps keys strictly ordered under random insertions', () => {
138
+ const keys = ['a0', 'a1'];
139
+ let seed = 42;
140
+ const random = () => {
141
+ seed = (seed * 1103515245 + 12345) % 2147483648;
142
+ return seed / 2147483648;
143
+ };
144
+
145
+ for (let i = 0; i < 500; i++) {
146
+ const idx = Math.floor(random() * (keys.length + 1));
147
+ const next = generateKeyBetween(keys[idx - 1] ?? null, keys[idx] ?? null);
148
+ keys.splice(idx, 0, next);
149
+ }
150
+
151
+ for (let i = 1; i < keys.length; i++) {
152
+ expect(keys[i - 1] < keys[i]).toBe(true);
153
+ }
154
+ });
155
+
156
+ it('throws when the lower bound does not sort before the upper bound', () => {
157
+ expect(() => generateKeyBetween('a1', 'a0')).toThrow(RangeError);
158
+ expect(() => generateKeyBetween('a0', 'a0')).toThrow(RangeError);
159
+ });
123
160
  });
124
161
 
125
162
  describe('generateNKeysBetween', () => {
@@ -108,40 +108,35 @@ export function getIntegerPart(key: string): string {
108
108
  }
109
109
 
110
110
  /**
111
- * Computes a fractional midpoint suffix between suffixA and suffixB.
111
+ * Computes a fractional suffix strictly between suffixes `a` and `b` (a < result < b).
112
+ * Missing digits in `a` are treated as '0', so `a` may be shorter than `b`.
113
+ * `b === null` means there is no upper bound.
112
114
  */
113
115
  function midpointSuffix(a: string, b: string | null): string {
114
- let i = 0;
115
- while (i < a.length && b !== null && i < b.length && a[i] === b[i]) {
116
- i++;
116
+ if (b !== null) {
117
+ let n = 0;
118
+ while (n < b.length && (a[n] ?? '0') === b[n]) {
119
+ n++;
120
+ }
121
+ if (n > 0) {
122
+ return b.slice(0, n) + midpointSuffix(a.slice(n), b.slice(n));
123
+ }
117
124
  }
118
125
 
119
- const common = a.slice(0, i);
120
- const charA = i < a.length ? a[i] : null;
121
- const charB = b !== null && i < b.length ? b[i] : null;
122
-
123
- const digitA = charA !== null ? BASE_62.indexOf(charA) : 0;
124
- const digitB = charB !== null ? BASE_62.indexOf(charB) : BASE_62.length;
126
+ const digitA = a.length > 0 ? BASE_62.indexOf(a[0]) : 0;
127
+ const digitB = b !== null ? BASE_62.indexOf(b[0]) : BASE_62.length;
125
128
 
126
129
  if (digitB - digitA > 1) {
127
- const mid = Math.floor((digitA + digitB) / 2);
128
- return common + BASE_62[mid];
130
+ return BASE_62[Math.floor((digitA + digitB) / 2)];
129
131
  }
130
132
 
131
- if (charA !== null) {
132
- const restA = a.slice(i + 1);
133
- const restB = b !== null && i < b.length && charA === charB ? b.slice(i + 1) : null;
134
- return common + charA + midpointSuffix(restA, restB);
135
- }
136
-
137
- if (b !== null && i < b.length) {
138
- if (digitB === 0) {
139
- const restB = b.slice(i + 1);
140
- return common + '0' + midpointSuffix('', restB);
141
- }
133
+ // Adjacent digits: if b has more digits, its first digit alone already sits between a and b.
134
+ if (b !== null && b.length > 1) {
135
+ return b[0];
142
136
  }
143
137
 
144
- return common + 'V';
138
+ // Otherwise keep a's digit and subdivide the remainder with no upper bound.
139
+ return BASE_62[digitA] + midpointSuffix(a.slice(1), null);
145
140
  }
146
141
 
147
142
  /**
@@ -185,8 +180,7 @@ export function generateKeyBetween(
185
180
 
186
181
  // Both a and b are present
187
182
  if (a! >= b!) {
188
- // Graceful fallback for inverted or equal keys
189
- return a! + 'V';
183
+ throw new RangeError(`generateKeyBetween: lower bound "${a}" must sort before upper bound "${b}"`);
190
184
  }
191
185
 
192
186
  const intA = getIntegerPart(a!);
@@ -1 +1 @@
1
- {"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/task-duration.test.ts","./src/domain/workload.test.ts","./src/domain/workload.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/fractional-index.test.ts","./src/utils/fractional-index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
1
+ {"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/errors.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/task-duration.test.ts","./src/domain/workload.test.ts","./src/domain/workload.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/fractional-index.test.ts","./src/utils/fractional-index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}