@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
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ CriticalPathEngine,
4
+ CustomFieldValidationError,
5
+ ValidationError,
6
+ WorkflowValidationError,
7
+ type CriticalPathPlugin
8
+ } from '../index.js';
9
+
10
+ const strictWorkflow = {
11
+ name: 'Strict',
12
+ defaultStatusKey: 'todo',
13
+ statuses: [
14
+ { key: 'todo', label: 'To Do', category: 'not_started' as const },
15
+ { key: 'doing', label: 'Doing', category: 'in_progress' as const },
16
+ { key: 'done', label: 'Done', category: 'completed' as const }
17
+ ],
18
+ transitions: [
19
+ { fromStatusKey: 'todo', toStatusKey: 'doing' },
20
+ { fromStatusKey: 'doing', toStatusKey: 'done' }
21
+ ]
22
+ };
23
+
24
+ describe('plugin system', () => {
25
+ it('runs init once with the engine before ready resolves', async () => {
26
+ const init = vi.fn(async (engine: CriticalPathEngine) => {
27
+ await engine.createProject({ name: 'Seeded by plugin' });
28
+ });
29
+ const engine = new CriticalPathEngine({ plugins: [{ id: 'seed', name: 'Seed', version: '1', init }] });
30
+ await engine.ready;
31
+
32
+ expect(init).toHaveBeenCalledTimes(1);
33
+ expect(init).toHaveBeenCalledWith(engine);
34
+ expect(await engine.getProjects()).toHaveLength(1);
35
+ });
36
+
37
+ it('surfaces init failures through ready without unhandled rejections', async () => {
38
+ const engine = new CriticalPathEngine({
39
+ plugins: [{ id: 'bad', name: 'Bad', version: '1', init: () => Promise.reject(new Error('boom')) }]
40
+ });
41
+ await expect(engine.ready).rejects.toThrow('boom');
42
+ });
43
+
44
+ it('validates values of plugin-registered custom field types and rejects unknown types', async () => {
45
+ const urlField: CriticalPathPlugin = {
46
+ id: 'url-field',
47
+ name: 'URL field',
48
+ version: '1',
49
+ customFieldTypes: [
50
+ { type: 'url', validate: (value) => (typeof value === 'string' && /^https?:\/\//.test(value) ? null : 'must be an http(s) URL') }
51
+ ]
52
+ };
53
+ const engine = new CriticalPathEngine({ plugins: [urlField] });
54
+ const project = await engine.createProject({
55
+ name: 'Links',
56
+ customFieldDefinitions: [{ id: 'f1', key: 'spec', label: 'Spec', type: 'url' }]
57
+ });
58
+
59
+ await engine.createTask({ projectId: project.id, title: 'ok', customFields: { spec: 'https://example.com' } });
60
+ await expect(engine.createTask({ projectId: project.id, title: 'bad', customFields: { spec: 'ftp://x' } })).rejects.toThrow(
61
+ /must be an http\(s\) URL/
62
+ );
63
+ await expect(
64
+ engine.createProject({ name: 'Unknown', customFieldDefinitions: [{ id: 'f', key: 'x', label: 'X', type: 'nope' }] })
65
+ ).rejects.toThrow(CustomFieldValidationError);
66
+ });
67
+
68
+ it('refuses custom field types that clash with built-in or already registered types', () => {
69
+ const clash = (type: string): CriticalPathPlugin => ({
70
+ id: `p-${type}-${Math.random()}`,
71
+ name: 'Clash',
72
+ version: '1',
73
+ customFieldTypes: [{ type, validate: () => null }]
74
+ });
75
+ expect(() => new CriticalPathEngine({ plugins: [clash('number')] })).toThrow(/already exists/);
76
+ expect(() => new CriticalPathEngine({ plugins: [clash('money'), clash('money')] })).toThrow(/already exists/);
77
+ });
78
+
79
+ it('validates the output of before-hooks, so plugins cannot bypass workflow rules', async () => {
80
+ const skipAhead: CriticalPathPlugin = {
81
+ id: 'skip',
82
+ name: 'Skip ahead',
83
+ version: '1',
84
+ hooks: { beforeTaskUpdate: (_id, updates) => ({ ...updates, status: 'done' }) }
85
+ };
86
+ const engine = new CriticalPathEngine({ plugins: [skipAhead] });
87
+ const workflow = await engine.createWorkflow(strictWorkflow);
88
+ const project = await engine.createProject({ name: 'Strict', workflowId: workflow.id });
89
+ const task = await engine.createTask({ projectId: project.id, title: 'T' });
90
+
91
+ await expect(engine.updateTask(task.id, { title: 'renamed' })).rejects.toThrow(WorkflowValidationError);
92
+ expect((await engine.getTask(task.id))?.status).toBe('todo');
93
+ });
94
+
95
+ it('prevents before-hooks from moving tasks between projects', async () => {
96
+ let otherProjectId = '';
97
+ const mover: CriticalPathPlugin = {
98
+ id: 'mover',
99
+ name: 'Mover',
100
+ version: '1',
101
+ hooks: {
102
+ beforeTaskCreate: (task) => (task.title === 'move me' ? { ...task, projectId: otherProjectId } : task),
103
+ beforeTaskUpdate: (_id, updates) => ({ ...updates, projectId: otherProjectId })
104
+ }
105
+ };
106
+ const engine = new CriticalPathEngine({ plugins: [mover] });
107
+ const home = await engine.createProject({ name: 'Home' });
108
+ otherProjectId = (await engine.createProject({ name: 'Other' })).id;
109
+
110
+ await expect(engine.createTask({ projectId: home.id, title: 'move me' })).rejects.toThrow(ValidationError);
111
+ const task = await engine.createTask({ projectId: home.id, title: 'stay' });
112
+ await engine.updateTask(task.id, { title: 'still here' });
113
+ expect((await engine.getTask(task.id))?.projectId).toBe(home.id);
114
+ });
115
+
116
+ it('enforces required custom fields even when none are supplied', async () => {
117
+ const engine = new CriticalPathEngine();
118
+ const project = await engine.createProject({
119
+ name: 'Required',
120
+ customFieldDefinitions: [{ id: 'f', key: 'client', label: 'Client', type: 'text', required: true }]
121
+ });
122
+ await expect(engine.createTask({ projectId: project.id, title: 'missing' })).rejects.toThrow(/required/);
123
+ });
124
+
125
+ it('logs after-hook failures without failing the write or skipping events', async () => {
126
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
127
+ const engine = new CriticalPathEngine({
128
+ plugins: [{ id: 'flaky', name: 'Flaky', version: '1', hooks: { afterTaskCreate: () => { throw new Error('downstream'); } } }]
129
+ });
130
+ const published: string[] = [];
131
+ engine.events.subscribe('task.created', (e) => void published.push(e.name));
132
+
133
+ const project = await engine.createProject({ name: 'After' });
134
+ const task = await engine.createTask({ projectId: project.id, title: 'stored' });
135
+ expect(await engine.getTask(task.id)).not.toBeNull();
136
+ expect(published).toEqual(['task.created']);
137
+ expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('"flaky" afterTaskCreate'), expect.any(Error));
138
+ consoleError.mockRestore();
139
+ });
140
+
141
+ it('passes the deleted task to delete hooks', async () => {
142
+ const seen: string[] = [];
143
+ const engine = new CriticalPathEngine({
144
+ plugins: [
145
+ {
146
+ id: 'audit',
147
+ name: 'Audit',
148
+ version: '1',
149
+ hooks: {
150
+ beforeTaskDelete: (_id, task) => void seen.push(`before:${task.title}`),
151
+ afterTaskDelete: (_id, task) => void seen.push(`after:${task.title}`)
152
+ }
153
+ }
154
+ ]
155
+ });
156
+ const project = await engine.createProject({ name: 'Del' });
157
+ const task = await engine.createTask({ projectId: project.id, title: 'gone' });
158
+ await engine.deleteTask(task.id);
159
+ expect(seen).toEqual(['before:gone', 'after:gone']);
160
+ });
161
+ });
@@ -65,7 +65,8 @@ export const CustomFieldDefinitionSchema = strictObject({
65
65
  id: z.string(),
66
66
  key: nonEmpty,
67
67
  label: z.string(),
68
- type: z.enum(['text', 'number', 'date', 'boolean', 'single_select', 'multi_select', 'user']),
68
+ /** Built-in type or one registered by a plugin (checked by the engine). */
69
+ type: nonEmpty,
69
70
  options: z.array(z.string()).optional(),
70
71
  required: z.boolean().optional(),
71
72
  defaultValue: z.unknown().optional()
@@ -104,6 +104,10 @@ export interface FirebaseStoreConfig {
104
104
  db: FirestoreDBInterface;
105
105
  }
106
106
 
107
+ /**
108
+ * Removes `undefined` values, which Firestore rejects. Update methods write the complete merged
109
+ * record without `{ merge: true }`, so a field cleared with `undefined` is actually removed.
110
+ */
107
111
  export function sanitizeFirestoreData<T>(obj: T): T {
108
112
  if (obj === null || typeof obj !== 'object') {
109
113
  return obj;
@@ -162,7 +166,7 @@ export class FirebaseStore implements StorageAdapter {
162
166
  ...updates,
163
167
  updatedAt: new Date().toISOString()
164
168
  };
165
- await this.db.collection('projects').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
169
+ await this.db.collection('projects').doc(id).set(sanitizeFirestoreData(updated));
166
170
  return updated;
167
171
  }
168
172
 
@@ -202,7 +206,7 @@ export class FirebaseStore implements StorageAdapter {
202
206
  ...updates,
203
207
  updatedAt: new Date().toISOString()
204
208
  };
205
- await this.db.collection('workflows').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
209
+ await this.db.collection('workflows').doc(id).set(sanitizeFirestoreData(updated));
206
210
  return updated;
207
211
  }
208
212
 
@@ -238,7 +242,7 @@ export class FirebaseStore implements StorageAdapter {
238
242
  if (!existing) return null;
239
243
 
240
244
  const updated: Team = { ...existing, ...updates, updatedAt: new Date().toISOString() };
241
- await this.db.collection('teams').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
245
+ await this.db.collection('teams').doc(id).set(sanitizeFirestoreData(updated));
242
246
  return updated;
243
247
  }
244
248
 
@@ -274,7 +278,7 @@ export class FirebaseStore implements StorageAdapter {
274
278
  if (!existing) return null;
275
279
 
276
280
  const updated: TaskContainer = { ...existing, ...updates, updatedAt: new Date().toISOString() };
277
- await this.db.collection('containers').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
281
+ await this.db.collection('containers').doc(id).set(sanitizeFirestoreData(updated));
278
282
  return updated;
279
283
  }
280
284
 
@@ -318,7 +322,7 @@ export class FirebaseStore implements StorageAdapter {
318
322
  if (!existing) return null;
319
323
 
320
324
  const updated: Deliverable = { ...existing, ...updates, updatedAt: new Date().toISOString() };
321
- await this.db.collection('deliverables').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
325
+ await this.db.collection('deliverables').doc(id).set(sanitizeFirestoreData(updated));
322
326
  return updated;
323
327
  }
324
328
 
@@ -363,7 +367,7 @@ export class FirebaseStore implements StorageAdapter {
363
367
  ...updates,
364
368
  updatedAt: new Date().toISOString()
365
369
  };
366
- await this.db.collection('tasks').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
370
+ await this.db.collection('tasks').doc(id).set(sanitizeFirestoreData(updated));
367
371
  return updated;
368
372
  }
369
373
 
@@ -399,7 +403,7 @@ export class FirebaseStore implements StorageAdapter {
399
403
  if (!snap.exists) return null;
400
404
 
401
405
  const updated: Iteration = { ...snap.data(), id, ...updates };
402
- await this.db.collection('iterations').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
406
+ await this.db.collection('iterations').doc(id).set(sanitizeFirestoreData(updated));
403
407
  return updated;
404
408
  }
405
409
 
@@ -439,7 +443,7 @@ export class FirebaseStore implements StorageAdapter {
439
443
  ...updates,
440
444
  updatedAt: new Date().toISOString()
441
445
  };
442
- await this.db.collection('comments').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
446
+ await this.db.collection('comments').doc(id).set(sanitizeFirestoreData(updated));
443
447
  return updated;
444
448
  }
445
449
 
@@ -567,6 +571,27 @@ export class FirebaseStore implements StorageAdapter {
567
571
  return newDep;
568
572
  }
569
573
 
574
+ async getDependency(id: string): Promise<TaskDependency | null> {
575
+ const doc = await this.db.collection('dependencies').doc(id).get();
576
+ return doc.exists ? ({ ...doc.data(), id: doc.id } as TaskDependency) : null;
577
+ }
578
+
579
+ async removeDependency(id: string): Promise<boolean> {
580
+ return this.deleteDoc('dependencies', id);
581
+ }
582
+
583
+ async deleteTimeEntry(id: string): Promise<boolean> {
584
+ return this.deleteDoc('time_entries', id);
585
+ }
586
+
587
+ private async deleteDoc(collection: string, id: string): Promise<boolean> {
588
+ const ref = this.db.collection(collection).doc(id);
589
+ const doc = await ref.get();
590
+ if (!doc.exists) return false;
591
+ await ref.delete();
592
+ return true;
593
+ }
594
+
570
595
  // --- Webhooks ---
571
596
  async getWebhooks(): Promise<Webhook[]> {
572
597
  const snap = await this.db.collection('webhooks').get();
@@ -92,11 +92,15 @@ export interface ActivityRepository {
92
92
  export interface TimeEntryRepository {
93
93
  getTimeEntries(taskId: string): Promise<TimeEntry[]>;
94
94
  logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string }): Promise<TimeEntry>;
95
+ deleteTimeEntry(id: string): Promise<boolean>;
95
96
  }
96
97
 
97
98
  export interface DependencyRepository {
99
+ /** Dependencies where `taskId` is either the dependent or the upstream task. */
98
100
  getDependencies(taskId: string): Promise<TaskDependency[]>;
101
+ getDependency(id: string): Promise<TaskDependency | null>;
99
102
  addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency>;
103
+ removeDependency(id: string): Promise<boolean>;
100
104
  }
101
105
 
102
106
  export interface WebhookRepository {
@@ -522,6 +526,10 @@ export class InMemoryStore implements StorageAdapter {
522
526
  return newEntry;
523
527
  }
524
528
 
529
+ async deleteTimeEntry(id: string): Promise<boolean> {
530
+ return this.timeEntries.delete(id);
531
+ }
532
+
525
533
  // Dependencies
526
534
  async getDependencies(taskId: string): Promise<TaskDependency[]> {
527
535
  return Array.from(this.dependencies.values()).filter(
@@ -529,6 +537,10 @@ export class InMemoryStore implements StorageAdapter {
529
537
  );
530
538
  }
531
539
 
540
+ async getDependency(id: string): Promise<TaskDependency | null> {
541
+ return this.dependencies.get(id) ?? null;
542
+ }
543
+
532
544
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
533
545
  const id = `dep_${Math.random().toString(36).substring(2, 9)}`;
534
546
  const newDep: TaskDependency = { ...dep, id };
@@ -536,6 +548,10 @@ export class InMemoryStore implements StorageAdapter {
536
548
  return newDep;
537
549
  }
538
550
 
551
+ async removeDependency(id: string): Promise<boolean> {
552
+ return this.dependencies.delete(id);
553
+ }
554
+
539
555
  // Webhooks
540
556
  async getWebhooks(): Promise<Webhook[]> {
541
557
  return Array.from(this.webhooks.values());
@@ -18,6 +18,14 @@ import type {
18
18
  } from '../types/index.js';
19
19
  import { generateProjectKey } from '../utils/key.js';
20
20
 
21
+ /**
22
+ * SQLite returns NULL columns as `null`, while domain objects leave unset optional fields out.
23
+ * Dropping nulls keeps SQLiteStore results identical to the other adapters.
24
+ */
25
+ function dropNulls<T extends object>(row: T): T {
26
+ return Object.fromEntries(Object.entries(row).filter(([, value]) => value !== null)) as T;
27
+ }
28
+
21
29
  export interface SQLiteStoreConfig {
22
30
  /**
23
31
  * Database file path (e.g., 'critical-path.db' or ':memory:').
@@ -516,13 +524,13 @@ export class SQLiteStore implements StorageAdapter {
516
524
  async getContainers(projectId: string): Promise<TaskContainer[]> {
517
525
  const stmt = this.db.prepare('SELECT * FROM containers WHERE projectId = ?');
518
526
  const rows = stmt.all(projectId) as any[];
519
- return rows;
527
+ return rows.map(dropNulls);
520
528
  }
521
529
 
522
530
  async getContainer(id: string): Promise<TaskContainer | null> {
523
531
  const stmt = this.db.prepare('SELECT * FROM containers WHERE id = ?');
524
532
  const row = stmt.get(id) as any;
525
- return row || null;
533
+ return row ? dropNulls(row) : null;
526
534
  }
527
535
 
528
536
  async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
@@ -821,13 +829,13 @@ export class SQLiteStore implements StorageAdapter {
821
829
  async getIterations(projectId: string): Promise<Iteration[]> {
822
830
  const stmt = this.db.prepare('SELECT * FROM iterations WHERE projectId = ?');
823
831
  const rows = stmt.all(projectId) as any[];
824
- return rows;
832
+ return rows.map(dropNulls);
825
833
  }
826
834
 
827
835
  async getIteration(id: string): Promise<Iteration | null> {
828
836
  const stmt = this.db.prepare('SELECT * FROM iterations WHERE id = ?');
829
837
  const row = stmt.get(id) as any;
830
- return row || null;
838
+ return row ? dropNulls(row) : null;
831
839
  }
832
840
 
833
841
  async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
@@ -883,7 +891,7 @@ export class SQLiteStore implements StorageAdapter {
883
891
 
884
892
  // --- Comments ---
885
893
  private mapComment(row: any): Comment {
886
- return {
894
+ return dropNulls({
887
895
  id: row.id,
888
896
  taskId: row.taskId,
889
897
  authorId: row.authorId,
@@ -895,7 +903,7 @@ export class SQLiteStore implements StorageAdapter {
895
903
  metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
896
904
  createdAt: row.createdAt,
897
905
  updatedAt: row.updatedAt
898
- };
906
+ });
899
907
  }
900
908
 
901
909
  async getComments(taskId: string): Promise<Comment[]> {
@@ -1028,22 +1036,24 @@ export class SQLiteStore implements StorageAdapter {
1028
1036
 
1029
1037
  const stmt = this.db.prepare(sql);
1030
1038
  const rows = stmt.all(...params) as any[];
1031
- return rows.map((r) => ({
1032
- ...r,
1033
- sizeBytes: Number(r.sizeBytes),
1034
- metadata: r.metadata ? JSON.parse(r.metadata) : undefined
1035
- }));
1039
+ return rows.map((r) =>
1040
+ dropNulls({
1041
+ ...r,
1042
+ sizeBytes: Number(r.sizeBytes),
1043
+ metadata: r.metadata ? JSON.parse(r.metadata) : undefined
1044
+ })
1045
+ );
1036
1046
  }
1037
1047
 
1038
1048
  async getAttachment(id: string): Promise<Attachment | null> {
1039
1049
  const stmt = this.db.prepare('SELECT * FROM attachments WHERE id = ?');
1040
1050
  const row = stmt.get(id) as any;
1041
1051
  if (!row) return null;
1042
- return {
1052
+ return dropNulls({
1043
1053
  ...row,
1044
1054
  sizeBytes: Number(row.sizeBytes),
1045
1055
  metadata: row.metadata ? JSON.parse(row.metadata) : undefined
1046
- };
1056
+ });
1047
1057
  }
1048
1058
 
1049
1059
  async createAttachment(attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Attachment> {
@@ -1106,10 +1116,12 @@ export class SQLiteStore implements StorageAdapter {
1106
1116
 
1107
1117
  const stmt = this.db.prepare(sql);
1108
1118
  const rows = stmt.all(...params) as any[];
1109
- return rows.map((r) => ({
1110
- ...r,
1111
- details: r.details ? JSON.parse(r.details) : undefined
1112
- }));
1119
+ return rows.map((r) =>
1120
+ dropNulls({
1121
+ ...r,
1122
+ details: r.details ? JSON.parse(r.details) : undefined
1123
+ })
1124
+ );
1113
1125
  }
1114
1126
 
1115
1127
  async logActivity(activity: Omit<Activity, 'id' | 'createdAt'>): Promise<Activity> {
@@ -1137,7 +1149,7 @@ export class SQLiteStore implements StorageAdapter {
1137
1149
  async getTimeEntries(taskId: string): Promise<TimeEntry[]> {
1138
1150
  const stmt = this.db.prepare('SELECT * FROM time_entries WHERE taskId = ?');
1139
1151
  const rows = stmt.all(taskId) as any[];
1140
- return rows.map((r) => ({ ...r, isBillable: r.isBillable !== null ? Boolean(r.isBillable) : undefined }));
1152
+ return rows.map((r) => dropNulls({ ...r, isBillable: r.isBillable !== null ? Boolean(r.isBillable) : undefined }));
1141
1153
  }
1142
1154
 
1143
1155
  async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string }): Promise<TimeEntry> {
@@ -1164,7 +1176,7 @@ export class SQLiteStore implements StorageAdapter {
1164
1176
  // --- Dependencies ---
1165
1177
  async getDependencies(taskId: string): Promise<TaskDependency[]> {
1166
1178
  const stmt = this.db.prepare('SELECT * FROM dependencies WHERE taskId = ? OR dependsOnTaskId = ?');
1167
- return stmt.all(taskId, taskId) as any[];
1179
+ return (stmt.all(taskId, taskId) as any[]).map(dropNulls);
1168
1180
  }
1169
1181
 
1170
1182
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
@@ -1179,6 +1191,19 @@ export class SQLiteStore implements StorageAdapter {
1179
1191
  return newDep;
1180
1192
  }
1181
1193
 
1194
+ async getDependency(id: string): Promise<TaskDependency | null> {
1195
+ const row = this.db.prepare('SELECT * FROM dependencies WHERE id = ?').get(id) as any;
1196
+ return row ? { id: row.id, taskId: row.taskId, dependsOnTaskId: row.dependsOnTaskId, type: row.type } : null;
1197
+ }
1198
+
1199
+ async removeDependency(id: string): Promise<boolean> {
1200
+ return Number(this.db.prepare('DELETE FROM dependencies WHERE id = ?').run(id).changes) > 0;
1201
+ }
1202
+
1203
+ async deleteTimeEntry(id: string): Promise<boolean> {
1204
+ return Number(this.db.prepare('DELETE FROM time_entries WHERE id = ?').run(id).changes) > 0;
1205
+ }
1206
+
1182
1207
  // --- Webhooks ---
1183
1208
  async getWebhooks(): Promise<Webhook[]> {
1184
1209
  const stmt = this.db.prepare('SELECT * FROM webhooks');
@@ -1215,7 +1240,7 @@ export class SQLiteStore implements StorageAdapter {
1215
1240
  }
1216
1241
 
1217
1242
  private mapWebhook(row: any): Webhook {
1218
- return {
1243
+ return dropNulls({
1219
1244
  id: row.id,
1220
1245
  name: row.name ?? '',
1221
1246
  url: row.url,
@@ -1224,7 +1249,7 @@ export class SQLiteStore implements StorageAdapter {
1224
1249
  active: Boolean(row.active),
1225
1250
  tenantId: row.tenantId ?? undefined,
1226
1251
  createdAt: row.createdAt
1227
- };
1252
+ });
1228
1253
  }
1229
1254
 
1230
1255
  async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
@@ -1251,7 +1276,7 @@ export class SQLiteStore implements StorageAdapter {
1251
1276
 
1252
1277
  // Helper mappers
1253
1278
  private mapProject(row: any): Project {
1254
- return {
1279
+ return dropNulls({
1255
1280
  ...row,
1256
1281
  tenantId: row.tenantId ?? undefined,
1257
1282
  workflowId: row.workflowId || undefined,
@@ -1261,30 +1286,30 @@ export class SQLiteStore implements StorageAdapter {
1261
1286
  statusDefinitions: row.statusDefinitions ? JSON.parse(row.statusDefinitions) : [],
1262
1287
  priorityDefinitions: row.priorityDefinitions ? JSON.parse(row.priorityDefinitions) : [],
1263
1288
  customFieldDefinitions: row.customFieldDefinitions ? JSON.parse(row.customFieldDefinitions) : []
1264
- };
1289
+ });
1265
1290
  }
1266
1291
 
1267
1292
  private mapWorkflow(row: any): Workflow {
1268
- return {
1293
+ return dropNulls({
1269
1294
  ...row,
1270
1295
  tenantId: row.tenantId ?? undefined,
1271
1296
  statuses: row.statuses ? JSON.parse(row.statuses) : [],
1272
1297
  transitions: row.transitions ? JSON.parse(row.transitions) : [],
1273
1298
  taskTypes: row.taskTypes ? JSON.parse(row.taskTypes) : undefined,
1274
1299
  isDefault: row.isDefault !== null && row.isDefault !== undefined ? Boolean(row.isDefault) : undefined
1275
- };
1300
+ });
1276
1301
  }
1277
1302
 
1278
1303
  private mapTeam(row: any): Team {
1279
- return {
1304
+ return dropNulls({
1280
1305
  ...row,
1281
1306
  tenantId: row.tenantId ?? undefined,
1282
1307
  memberIds: row.memberIds ? JSON.parse(row.memberIds) : []
1283
- };
1308
+ });
1284
1309
  }
1285
1310
 
1286
1311
  private mapTask(row: any): Task {
1287
- return {
1312
+ return dropNulls({
1288
1313
  ...row,
1289
1314
  actualDurationSeconds: row.actualDurationSeconds ?? undefined,
1290
1315
  inProgressSince: row.inProgressSince || undefined,
@@ -1299,15 +1324,15 @@ export class SQLiteStore implements StorageAdapter {
1299
1324
  tags: row.tags ? JSON.parse(row.tags) : [],
1300
1325
  assignees: row.assignees ? JSON.parse(row.assignees) : undefined,
1301
1326
  customFields: row.customFields ? JSON.parse(row.customFields) : {}
1302
- };
1327
+ });
1303
1328
  }
1304
1329
 
1305
1330
  private mapDeliverable(row: any): Deliverable {
1306
- return {
1331
+ return dropNulls({
1307
1332
  ...row,
1308
1333
  specs: row.specs ? JSON.parse(row.specs) : undefined,
1309
1334
  outputUrls: row.outputUrls ? JSON.parse(row.outputUrls) : [],
1310
1335
  customFields: row.customFields ? JSON.parse(row.customFields) : {}
1311
- };
1336
+ });
1312
1337
  }
1313
1338
  }
@@ -88,7 +88,8 @@ export interface CustomFieldDefinition {
88
88
  id: string;
89
89
  key: string;
90
90
  label: string;
91
- type: 'text' | 'number' | 'date' | 'boolean' | 'single_select' | 'multi_select' | 'user';
91
+ /** A built-in type, or a type registered by a plugin's `customFieldTypes`. */
92
+ type: 'text' | 'number' | 'date' | 'boolean' | 'single_select' | 'multi_select' | 'user' | (string & {});
92
93
  options?: string[];
93
94
  required?: boolean;
94
95
  defaultValue?: unknown;
@@ -448,23 +449,74 @@ export type PublicWebhook = Omit<Webhook, 'secret'> & { hasSecret: boolean };
448
449
  */
449
450
  export type WebhookEvent = import('../domain/events.js').CriticalPathDomainEvent['name'] | '*';
450
451
 
452
+ /**
453
+ * Lifecycle hooks, run in plugin registration order.
454
+ *
455
+ * - `before*` hooks may transform the input or throw to abort the operation. Their output is
456
+ * validated (workflow transitions, custom fields) exactly like caller input, and they cannot
457
+ * move a task to another project.
458
+ * - `after*` hooks run once the change is stored. Errors are logged and do not fail the call.
459
+ */
451
460
  export interface PluginHooks {
452
461
  beforeTaskCreate?: (task: Partial<Task>) => Promise<Partial<Task>> | Partial<Task>;
453
462
  afterTaskCreate?: (task: Task) => Promise<void> | void;
454
463
  beforeTaskUpdate?: (id: string, updates: Partial<Task>) => Promise<Partial<Task>> | Partial<Task>;
455
464
  afterTaskUpdate?: (task: Task, previousState: Task) => Promise<void> | void;
456
- beforeTaskDelete?: (id: string) => Promise<void> | void;
457
- afterTaskDelete?: (id: string) => Promise<void> | void;
465
+ beforeTaskDelete?: (id: string, task: Task) => Promise<void> | void;
466
+ afterTaskDelete?: (id: string, task: Task) => Promise<void> | void;
467
+ }
468
+
469
+ /** A custom field type contributed by a plugin, e.g. `url` or `currency`. */
470
+ export interface CustomFieldType {
471
+ /** The `type` value used in `CustomFieldDefinition`s. Must not clash with a built-in type. */
472
+ type: string;
473
+ label?: string;
474
+ /** Returns an error message for an invalid value, or nothing when valid. Not called for empty values. */
475
+ validate: (value: unknown, definition: CustomFieldDefinition) => string | null | undefined | void;
476
+ }
477
+
478
+ /** Context passed to plugin routes and middleware. */
479
+ export interface PluginRequestContext {
480
+ /** The engine as the calling actor (authorization and tenancy apply). */
481
+ engine: import('../engine/index.js').CriticalPathEngine;
482
+ /** The caller resolved by the router's `getContext`, if any. */
483
+ context?: Record<string, unknown> & { userId?: string };
484
+ url: URL;
485
+ /** Path parameters from the route pattern, e.g. `{ projectId: 'p1' }` for `/reports/:projectId`. */
486
+ params: Record<string, string>;
458
487
  }
459
488
 
489
+ export interface PluginRoute {
490
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
491
+ /** Path below the router's base, with `:name` parameters, e.g. `/reports/:projectId`. */
492
+ path: string;
493
+ handler: (request: Request, ctx: PluginRequestContext) => Response | Promise<Response>;
494
+ }
495
+
496
+ /**
497
+ * Wraps every routed request (after authentication). Call `next()` to continue, or return a
498
+ * `Response` to short-circuit (e.g. rate limiting).
499
+ */
500
+ export type PluginMiddleware = (
501
+ request: Request,
502
+ ctx: Omit<PluginRequestContext, 'params'>,
503
+ next: () => Promise<Response>
504
+ ) => Response | Promise<Response>;
505
+
460
506
  export interface CriticalPathPlugin {
461
507
  id: string;
462
508
  name: string;
463
509
  version: string;
464
510
  description?: string;
465
511
  hooks?: PluginHooks;
466
- customFieldTypes?: CustomFieldDefinition[];
467
- init?: (engine: unknown) => Promise<void> | void;
512
+ /** Additional custom field types projects can use in `customFieldDefinitions`. */
513
+ customFieldTypes?: CustomFieldType[];
514
+ /** Runs once when the engine starts; `engine.ready` resolves after every plugin's `init`. */
515
+ init?: (engine: import('../engine/index.js').CriticalPathEngine) => Promise<void> | void;
516
+ /** HTTP routes served by `@critical-path/server`, matched before built-in routes. */
517
+ routes?: PluginRoute[];
518
+ /** Middleware run by `@critical-path/server` around every routed request. */
519
+ middleware?: PluginMiddleware;
468
520
  }
469
521
 
470
522
  export interface CriticalPathConfig {
@@ -78,7 +78,8 @@ describe('webhook delivery', () => {
78
78
  await engine.createTask({ projectId: project.id, title: 'T' });
79
79
  await engine.webhooks.idle();
80
80
 
81
- expect(deliveries.map((d) => d.body.event)).toEqual(['project.created', 'task.created']);
81
+ // Deliveries are asynchronous, so arrival order is not guaranteed
82
+ expect(deliveries.map((d) => d.body.event).sort()).toEqual(['project.created', 'task.created']);
82
83
  expect(deliveries.every((d) => d.url.endsWith('/all'))).toBe(true);
83
84
  });
84
85