@critical-path/core 0.32.0 → 0.34.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 +19 -0
  2. package/README.md +12 -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/conformance.test.d.ts +2 -0
  19. package/dist/store/conformance.test.d.ts.map +1 -0
  20. package/dist/store/conformance.test.js +15 -0
  21. package/dist/store/conformance.test.js.map +1 -0
  22. package/dist/store/firebase.d.ts.map +1 -1
  23. package/dist/store/firebase.js +14 -3
  24. package/dist/store/firebase.js.map +1 -1
  25. package/dist/store/index.js +13 -13
  26. package/dist/store/index.js.map +1 -1
  27. package/dist/store/sqlite.d.ts +6 -0
  28. package/dist/store/sqlite.d.ts.map +1 -1
  29. package/dist/store/sqlite.js +59 -18
  30. package/dist/store/sqlite.js.map +1 -1
  31. package/dist/testing/conformance.d.ts +36 -0
  32. package/dist/testing/conformance.d.ts.map +1 -0
  33. package/dist/testing/conformance.js +244 -0
  34. package/dist/testing/conformance.js.map +1 -0
  35. package/dist/testing/index.d.ts +2 -0
  36. package/dist/testing/index.d.ts.map +1 -0
  37. package/dist/testing/index.js +2 -0
  38. package/dist/testing/index.js.map +1 -0
  39. package/package.json +5 -1
  40. package/src/domain/entities.ts +17 -17
  41. package/src/domain/events.ts +49 -0
  42. package/src/engine/entity-events.test.ts +67 -0
  43. package/src/engine/index.ts +115 -39
  44. package/src/store/conformance.test.ts +15 -0
  45. package/src/store/firebase.ts +17 -3
  46. package/src/store/index.ts +13 -13
  47. package/src/store/sqlite.ts +67 -18
  48. package/src/testing/conformance.ts +301 -0
  49. package/src/testing/index.ts +1 -0
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -23,6 +23,9 @@ import { generateProjectKey } from '../utils/key.js';
23
23
  * SQLite returns NULL columns as `null`, while domain objects leave unset optional fields out.
24
24
  * Dropping nulls keeps SQLiteStore results identical to the other adapters.
25
25
  */
26
+ const EXTRA_TABLES = ['projects', 'tasks', 'teams', 'attachments'] as const;
27
+ type ExtraTable = (typeof EXTRA_TABLES)[number];
28
+
26
29
  function dropNulls<T extends object>(row: T): T {
27
30
  return Object.fromEntries(Object.entries(row).filter(([, value]) => value !== null)) as T;
28
31
  }
@@ -308,6 +311,16 @@ export class SQLiteStore implements StorageAdapter {
308
311
  }
309
312
  }
310
313
 
314
+ // Fields without a dedicated column are kept in a JSON `extra` column, so entities round-trip
315
+ // even when their types gain fields this schema does not know about.
316
+ for (const table of EXTRA_TABLES) {
317
+ try {
318
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN extra TEXT`);
319
+ } catch {
320
+ // Column may already exist
321
+ }
322
+ }
323
+
311
324
  for (const table of ['projects', 'workflows', 'teams']) {
312
325
  try {
313
326
  this.db.exec(`ALTER TABLE ${table} ADD COLUMN tenantId TEXT`);
@@ -317,6 +330,35 @@ export class SQLiteStore implements StorageAdapter {
317
330
  }
318
331
  }
319
332
 
333
+ private columnCache = new Map<string, Set<string>>();
334
+
335
+ private columnsOf(table: ExtraTable): Set<string> {
336
+ let columns = this.columnCache.get(table);
337
+ if (!columns) {
338
+ const info = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
339
+ columns = new Set(info.map((c) => c.name));
340
+ this.columnCache.set(table, columns);
341
+ }
342
+ return columns;
343
+ }
344
+
345
+ /** Stores every defined field that has no dedicated column in the row's `extra` JSON. */
346
+ private writeExtra(table: ExtraTable, entity: object): void {
347
+ const columns = this.columnsOf(table);
348
+ const extra = Object.fromEntries(
349
+ Object.entries(entity).filter(([key, value]) => !columns.has(key) && value !== undefined)
350
+ );
351
+ this.db
352
+ .prepare(`UPDATE ${table} SET extra = ? WHERE id = ?`)
353
+ .run(Object.keys(extra).length ? JSON.stringify(extra) : null, (entity as { id: string }).id);
354
+ }
355
+
356
+ /** Merges the `extra` JSON back into a row. */
357
+ private expandExtra(row: any): any {
358
+ const { extra, ...rest } = row;
359
+ return extra ? { ...rest, ...JSON.parse(extra) } : rest;
360
+ }
361
+
320
362
  // --- Projects ---
321
363
  async getProjects(): Promise<Project[]> {
322
364
  const stmt = this.db.prepare('SELECT * FROM projects');
@@ -331,7 +373,7 @@ export class SQLiteStore implements StorageAdapter {
331
373
  }
332
374
 
333
375
  async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
334
- const id = `proj_${Math.random().toString(36).substring(2, 9)}`;
376
+ const id = `proj_${crypto.randomUUID()}`;
335
377
  const now = new Date().toISOString();
336
378
  const key = project.key || generateProjectKey(project.name);
337
379
  const newProj: Project = { ...project, key, id, createdAt: now, updatedAt: now };
@@ -357,6 +399,7 @@ export class SQLiteStore implements StorageAdapter {
357
399
  newProj.createdAt,
358
400
  newProj.updatedAt
359
401
  );
402
+ this.writeExtra('projects', newProj);
360
403
  return newProj;
361
404
  }
362
405
 
@@ -390,6 +433,7 @@ export class SQLiteStore implements StorageAdapter {
390
433
  updated.updatedAt,
391
434
  id
392
435
  );
436
+ this.writeExtra('projects', updated);
393
437
  return updated;
394
438
  }
395
439
 
@@ -407,7 +451,7 @@ export class SQLiteStore implements StorageAdapter {
407
451
  }
408
452
 
409
453
  async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
410
- const id = `wf_${Math.random().toString(36).substring(2, 9)}`;
454
+ const id = `wf_${crypto.randomUUID()}`;
411
455
  const now = new Date().toISOString();
412
456
  const newWf: Workflow = { ...workflow, id, createdAt: now, updatedAt: now };
413
457
 
@@ -485,7 +529,7 @@ export class SQLiteStore implements StorageAdapter {
485
529
  }
486
530
 
487
531
  async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
488
- const id = `team_${Math.random().toString(36).substring(2, 9)}`;
532
+ const id = `team_${crypto.randomUUID()}`;
489
533
  const now = new Date().toISOString();
490
534
  const newTeam: Team = { ...team, id, createdAt: now, updatedAt: now };
491
535
 
@@ -503,6 +547,7 @@ export class SQLiteStore implements StorageAdapter {
503
547
  newTeam.createdAt,
504
548
  newTeam.updatedAt
505
549
  );
550
+ this.writeExtra('teams', newTeam);
506
551
  return newTeam;
507
552
  }
508
553
 
@@ -523,6 +568,7 @@ export class SQLiteStore implements StorageAdapter {
523
568
  updated.updatedAt,
524
569
  id
525
570
  );
571
+ this.writeExtra('teams', updated);
526
572
  return updated;
527
573
  }
528
574
 
@@ -546,7 +592,7 @@ export class SQLiteStore implements StorageAdapter {
546
592
  }
547
593
 
548
594
  async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
549
- const id = `cnt_${Math.random().toString(36).substring(2, 9)}`;
595
+ const id = `cnt_${crypto.randomUUID()}`;
550
596
  const now = new Date().toISOString();
551
597
  const newContainer: TaskContainer = { ...container, id, createdAt: now, updatedAt: now };
552
598
 
@@ -609,7 +655,7 @@ export class SQLiteStore implements StorageAdapter {
609
655
  }
610
656
 
611
657
  async createDeliverable(deliverable: CreateDeliverableInput): Promise<Deliverable> {
612
- const id = `deliv_${Math.random().toString(36).substring(2, 9)}`;
658
+ const id = `deliv_${crypto.randomUUID()}`;
613
659
  const now = new Date().toISOString();
614
660
  const newDeliverable: Deliverable = {
615
661
  ...deliverable,
@@ -756,7 +802,7 @@ export class SQLiteStore implements StorageAdapter {
756
802
  }
757
803
 
758
804
  async createTask(task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<Task> {
759
- const id = `task_${Math.random().toString(36).substring(2, 9)}`;
805
+ const id = `task_${crypto.randomUUID()}`;
760
806
  const now = new Date().toISOString();
761
807
  const newTask: Task = { ...task, id, createdAt: now, updatedAt: now };
762
808
 
@@ -812,6 +858,7 @@ export class SQLiteStore implements StorageAdapter {
812
858
  newTask.createdAt,
813
859
  newTask.updatedAt
814
860
  );
861
+ this.writeExtra('tasks', newTask);
815
862
  return newTask;
816
863
  }
817
864
 
@@ -877,6 +924,7 @@ export class SQLiteStore implements StorageAdapter {
877
924
  updated.updatedAt,
878
925
  id
879
926
  );
927
+ this.writeExtra('tasks', updated);
880
928
  return updated;
881
929
  }
882
930
 
@@ -900,7 +948,7 @@ export class SQLiteStore implements StorageAdapter {
900
948
  }
901
949
 
902
950
  async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
903
- const id = `iter_${Math.random().toString(36).substring(2, 9)}`;
951
+ const id = `iter_${crypto.randomUUID()}`;
904
952
  const now = new Date().toISOString();
905
953
  const newIteration: Iteration = { ...iteration, id, createdAt: now };
906
954
 
@@ -981,7 +1029,7 @@ export class SQLiteStore implements StorageAdapter {
981
1029
  }
982
1030
 
983
1031
  async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
984
- const id = `cmt_${Math.random().toString(36).substring(2, 9)}`;
1032
+ const id = `cmt_${crypto.randomUUID()}`;
985
1033
  const now = new Date().toISOString();
986
1034
  const newComment: Comment = { ...comment, id, createdAt: now, updatedAt: now };
987
1035
 
@@ -1099,7 +1147,7 @@ export class SQLiteStore implements StorageAdapter {
1099
1147
  const rows = stmt.all(...params) as any[];
1100
1148
  return rows.map((r) =>
1101
1149
  dropNulls({
1102
- ...r,
1150
+ ...this.expandExtra(r),
1103
1151
  sizeBytes: Number(r.sizeBytes),
1104
1152
  metadata: r.metadata ? JSON.parse(r.metadata) : undefined
1105
1153
  })
@@ -1111,14 +1159,14 @@ export class SQLiteStore implements StorageAdapter {
1111
1159
  const row = stmt.get(id) as any;
1112
1160
  if (!row) return null;
1113
1161
  return dropNulls({
1114
- ...row,
1162
+ ...this.expandExtra(row),
1115
1163
  sizeBytes: Number(row.sizeBytes),
1116
1164
  metadata: row.metadata ? JSON.parse(row.metadata) : undefined
1117
1165
  });
1118
1166
  }
1119
1167
 
1120
1168
  async createAttachment(attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Attachment> {
1121
- const id = `att_${Math.random().toString(36).substring(2, 9)}`;
1169
+ const id = `att_${crypto.randomUUID()}`;
1122
1170
  const now = new Date().toISOString();
1123
1171
  const newAtt: Attachment = {
1124
1172
  ...attachment,
@@ -1147,6 +1195,7 @@ export class SQLiteStore implements StorageAdapter {
1147
1195
  newAtt.createdAt,
1148
1196
  newAtt.updatedAt
1149
1197
  );
1198
+ this.writeExtra('attachments', newAtt);
1150
1199
  return newAtt;
1151
1200
  }
1152
1201
 
@@ -1218,7 +1267,7 @@ export class SQLiteStore implements StorageAdapter {
1218
1267
  }
1219
1268
 
1220
1269
  async logActivity(activity: Omit<Activity, 'id' | 'createdAt'>): Promise<Activity> {
1221
- const id = `act_${Math.random().toString(36).substring(2, 9)}`;
1270
+ const id = `act_${crypto.randomUUID()}`;
1222
1271
  const now = new Date().toISOString();
1223
1272
  const newAct: Activity = { ...activity, id, createdAt: now };
1224
1273
 
@@ -1246,7 +1295,7 @@ export class SQLiteStore implements StorageAdapter {
1246
1295
  }
1247
1296
 
1248
1297
  async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string }): Promise<TimeEntry> {
1249
- const id = `time_${Math.random().toString(36).substring(2, 9)}`;
1298
+ const id = `time_${crypto.randomUUID()}`;
1250
1299
  const now = entry.loggedAt || new Date().toISOString();
1251
1300
  const newEntry: TimeEntry = { ...entry, id, loggedAt: now };
1252
1301
 
@@ -1273,7 +1322,7 @@ export class SQLiteStore implements StorageAdapter {
1273
1322
  }
1274
1323
 
1275
1324
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
1276
- const id = `dep_${Math.random().toString(36).substring(2, 9)}`;
1325
+ const id = `dep_${crypto.randomUUID()}`;
1277
1326
  const newDep: TaskDependency = { ...dep, id };
1278
1327
 
1279
1328
  const stmt = this.db.prepare(`
@@ -1346,7 +1395,7 @@ export class SQLiteStore implements StorageAdapter {
1346
1395
  }
1347
1396
 
1348
1397
  async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
1349
- const id = `wh_${Math.random().toString(36).substring(2, 9)}`;
1398
+ const id = `wh_${crypto.randomUUID()}`;
1350
1399
  const now = new Date().toISOString();
1351
1400
  const newWh: Webhook = { ...webhook, id, createdAt: now };
1352
1401
 
@@ -1370,7 +1419,7 @@ export class SQLiteStore implements StorageAdapter {
1370
1419
  // Helper mappers
1371
1420
  private mapProject(row: any): Project {
1372
1421
  return dropNulls({
1373
- ...row,
1422
+ ...this.expandExtra(row),
1374
1423
  tenantId: row.tenantId ?? undefined,
1375
1424
  workflowId: row.workflowId || undefined,
1376
1425
  taskTypes: row.taskTypes ? JSON.parse(row.taskTypes) : undefined,
@@ -1395,7 +1444,7 @@ export class SQLiteStore implements StorageAdapter {
1395
1444
 
1396
1445
  private mapTeam(row: any): Team {
1397
1446
  return dropNulls({
1398
- ...row,
1447
+ ...this.expandExtra(row),
1399
1448
  tenantId: row.tenantId ?? undefined,
1400
1449
  memberIds: row.memberIds ? JSON.parse(row.memberIds) : []
1401
1450
  });
@@ -1403,7 +1452,7 @@ export class SQLiteStore implements StorageAdapter {
1403
1452
 
1404
1453
  private mapTask(row: any): Task {
1405
1454
  return dropNulls({
1406
- ...row,
1455
+ ...this.expandExtra(row),
1407
1456
  actualDurationSeconds: row.actualDurationSeconds ?? undefined,
1408
1457
  inProgressSince: row.inProgressSince || undefined,
1409
1458
  blockedDurationSeconds: row.blockedDurationSeconds ?? undefined,
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Storage adapter conformance suite. Run it against any `StorageAdapter` (including your own) to
3
+ * check it behaves like the built-in adapters:
4
+ *
5
+ * import { describe, it, expect } from 'vitest';
6
+ * import { runStorageAdapterConformance } from '@critical-path/core/testing';
7
+ * runStorageAdapterConformance({ name: 'PostgresStore', createStore: () => new PostgresStore(...), describe, it, expect });
8
+ *
9
+ * The test runner functions are passed in so core has no test-framework dependency.
10
+ */
11
+ import type { StorageAdapter } from '../store/index.js';
12
+ import type { Project, Task, Team, Workflow, Attachment, Deliverable, TaskContainer, Iteration, Comment } from '../types/index.js';
13
+
14
+ type Describe = (name: string, fn: () => void) => void;
15
+ type It = (name: string, fn: () => Promise<void> | void) => void;
16
+ // Minimal expect surface used by the suite (compatible with vitest and jest)
17
+ type Expect = (value: unknown) => {
18
+ toEqual(expected: unknown): void;
19
+ toBe(expected: unknown): void;
20
+ toBeNull(): void;
21
+ toBeUndefined(): void;
22
+ toBeDefined(): void;
23
+ toHaveLength(length: number): void;
24
+ toMatchObject(expected: object): void;
25
+ not: { toBeNull(): void; toHaveProperty(key: string): void };
26
+ };
27
+
28
+ export interface ConformanceOptions {
29
+ name: string;
30
+ createStore: () => StorageAdapter | Promise<StorageAdapter>;
31
+ describe: Describe;
32
+ it: It;
33
+ expect: Expect;
34
+ }
35
+
36
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
37
+
38
+ const schedule = {
39
+ name: 'Four-day week',
40
+ timezone: 'Europe/London',
41
+ defaultHoursPerDay: 8,
42
+ days: [{ dayOfWeek: 1, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] }],
43
+ holidays: [{ date: '2026-12-25', name: 'Christmas' }]
44
+ };
45
+
46
+ /** A project with every optional field set. */
47
+ const fullProject = (): Omit<Project, 'id' | 'createdAt' | 'updatedAt'> => ({
48
+ key: 'CONF',
49
+ name: 'Conformance',
50
+ description: 'Every field',
51
+ ownerId: 'owner-1',
52
+ members: [{ userId: 'u1', role: 'admin' }],
53
+ teamIds: ['team-1'],
54
+ workflowId: 'wf-1',
55
+ taskTypes: [{ key: 'bug', label: 'Bug' }],
56
+ statusDefinitions: [{ key: 'todo', label: 'To Do', category: 'not_started' }],
57
+ priorityDefinitions: [{ key: 'p1', label: 'P1', level: 1 }],
58
+ customFieldDefinitions: [{ id: 'f1', key: 'client', label: 'Client', type: 'text', required: false }],
59
+ schedule,
60
+ startDate: '2026-10-01',
61
+ targetEndDate: '2026-12-31',
62
+ tenantId: 'tenant-1'
63
+ });
64
+
65
+ /** A task with every optional field set. */
66
+ const fullTask = (projectId: string): Omit<Task, 'id' | 'createdAt' | 'updatedAt'> => ({
67
+ projectId,
68
+ key: 'CONF-1',
69
+ title: 'Every field',
70
+ description: 'All of them',
71
+ status: 'in_progress',
72
+ semanticStatus: 'in_progress',
73
+ priority: 'high',
74
+ taskType: 'bug',
75
+ assigneeId: 'u1',
76
+ assignees: [{ id: 'u1', name: 'Ana', role: 'lead', type: 'user', avatarUrl: 'https://example.com/a.png' }],
77
+ reporterId: 'u2',
78
+ reviewerId: 'u3',
79
+ iterationId: 'it-1',
80
+ teamId: 'team-1',
81
+ containerId: 'c-1',
82
+ deliverableId: 'd-1',
83
+ plannedStartDate: '2026-10-01',
84
+ actualStartDate: '2026-10-02T09:00:00.000Z',
85
+ dueDate: '2026-10-10',
86
+ estimatedHours: 8,
87
+ loggedHours: 2,
88
+ actualHours: 2,
89
+ billableHours: 1,
90
+ estimatedDurationMinutes: 480,
91
+ actualDurationMinutes: 120,
92
+ billableDurationMinutes: 60,
93
+ actualDurationSeconds: 7200,
94
+ inProgressSince: '2026-10-02T09:00:00.000Z',
95
+ blockedDurationSeconds: 60,
96
+ progress: 25,
97
+ isBlocked: false,
98
+ tags: ['a', 'b'],
99
+ todos: [{ id: 'todo-1', title: 'First', completed: false, createdAt: '2026-10-02T09:00:00.000Z' }],
100
+ customFields: { client: 'Acme', nested: { level: 2 } },
101
+ parentId: 'parent-1'
102
+ });
103
+
104
+ export function runStorageAdapterConformance({ name, createStore, describe, it, expect }: ConformanceOptions): void {
105
+ describe(`StorageAdapter conformance: ${name}`, () => {
106
+ it('round-trips every project field', async () => {
107
+ const store = await createStore();
108
+ const input = fullProject();
109
+ const created = await store.createProject(input);
110
+ expect(await store.getProject(created.id)).toMatchObject(input);
111
+ expect(await store.getProjects()).toHaveLength(1);
112
+ });
113
+
114
+ it('round-trips every task field', async () => {
115
+ const store = await createStore();
116
+ const project = await store.createProject({ name: 'P' });
117
+ const input = fullTask(project.id);
118
+ const created = await store.createTask(input);
119
+ expect(await store.getTask(created.id)).toMatchObject(input);
120
+ });
121
+
122
+ it('updates tasks partially, clears fields set to undefined, and replaces nested objects', async () => {
123
+ const store = await createStore();
124
+ const project = await store.createProject({ name: 'P' });
125
+ const created = await store.createTask(fullTask(project.id));
126
+
127
+ await store.updateTask(created.id, {
128
+ title: 'Renamed',
129
+ completedAt: undefined,
130
+ parentId: undefined,
131
+ iterationId: undefined,
132
+ customFields: { client: 'Globex' }
133
+ });
134
+ const updated = (await store.getTask(created.id)) as unknown as Record<string, unknown>;
135
+ expect(updated.title).toBe('Renamed');
136
+ expect(updated.description).toBe('All of them');
137
+ expect(updated.parentId).toBeUndefined();
138
+ expect(updated.iterationId).toBeUndefined();
139
+ expect(updated.customFields).toEqual({ client: 'Globex' });
140
+ expect(updated.parentId === null).toBe(false);
141
+ });
142
+
143
+ it('follows the not-found contract', async () => {
144
+ const store = await createStore();
145
+ expect(await store.getProject('missing')).toBeNull();
146
+ expect(await store.getTask('missing')).toBeNull();
147
+ expect(await store.updateTask('missing', { title: 'x' })).toBeNull();
148
+ expect(await store.updateProject('missing', { name: 'x' })).toBeNull();
149
+ expect(await store.deleteTask('missing')).toBe(false);
150
+ expect(await store.deleteProject('missing')).toBe(false);
151
+ expect(await store.getComment('missing')).toBeNull();
152
+ expect(await store.deleteComment('missing')).toBe(false);
153
+ expect(await store.getAttachment('missing')).toBeNull();
154
+ expect(await store.deleteAttachment('missing')).toBe(false);
155
+ expect(await store.getDependency('missing')).toBeNull();
156
+ expect(await store.removeDependency('missing')).toBe(false);
157
+ expect(await store.getWebhook('missing')).toBeNull();
158
+ expect(await store.deleteWebhook('missing')).toBe(false);
159
+ });
160
+
161
+ it('round-trips teams, workflows, containers, iterations and deliverables', async () => {
162
+ const store = await createStore();
163
+ const project = await store.createProject({ name: 'P' });
164
+
165
+ const team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'> = {
166
+ name: 'Core',
167
+ description: 'Team',
168
+ leaderId: 'u1',
169
+ memberIds: ['u1', 'u2'],
170
+ weeklyCapacityHours: 80,
171
+ schedule,
172
+ tenantId: 'tenant-1'
173
+ };
174
+ expect(await store.getTeam((await store.createTeam(team)).id)).toMatchObject(team);
175
+
176
+ const workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'> = {
177
+ name: 'Flow',
178
+ description: 'Workflow',
179
+ statuses: [{ key: 'todo', label: 'To Do', category: 'not_started' }],
180
+ transitions: [{ id: 't1', name: 'Start', fromStatusKey: '*', toStatusKey: 'todo' }],
181
+ taskTypes: [{ key: 'bug', label: 'Bug' }],
182
+ defaultStatusKey: 'todo',
183
+ isDefault: true,
184
+ tenantId: 'tenant-1'
185
+ };
186
+ expect(await store.getWorkflow((await store.createWorkflow(workflow)).id)).toMatchObject(workflow);
187
+
188
+ const container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'> = {
189
+ projectId: project.id,
190
+ name: 'Epic',
191
+ description: 'Container',
192
+ parentId: 'c-0',
193
+ type: 'epic',
194
+ color: '#ff0000'
195
+ };
196
+ expect(await store.getContainer((await store.createContainer(container)).id)).toMatchObject(container);
197
+
198
+ const iteration: Omit<Iteration, 'id' | 'createdAt'> = {
199
+ projectId: project.id,
200
+ name: 'Sprint 1',
201
+ goal: 'Ship',
202
+ type: 'sprint',
203
+ startDate: '2026-10-01',
204
+ endDate: '2026-10-14',
205
+ status: 'active'
206
+ };
207
+ expect(await store.getIteration((await store.createIteration(iteration)).id)).toMatchObject(iteration);
208
+
209
+ const deliverable: Omit<Deliverable, 'id' | 'createdAt' | 'updatedAt'> = {
210
+ projectId: project.id,
211
+ title: 'Cut',
212
+ description: 'Deliverable',
213
+ status: 'planned',
214
+ format: '4K',
215
+ specs: { fps: 24 },
216
+ leadId: 'u1',
217
+ reviewerId: 'u2',
218
+ dueDate: '2026-11-01',
219
+ deliveredAt: '2026-11-02T00:00:00.000Z',
220
+ outputUrls: ['https://example.com/cut.mov'],
221
+ customFields: { client: 'Acme' }
222
+ };
223
+ expect(await store.getDeliverable((await store.createDeliverable(deliverable)).id)).toMatchObject(deliverable);
224
+ });
225
+
226
+ it('round-trips comments with reactions, ordered oldest first', async () => {
227
+ const store = await createStore();
228
+ const project = await store.createProject({ name: 'P' });
229
+ const task = await store.createTask({ projectId: project.id, title: 'T', status: 'todo', priority: 'medium' });
230
+ const input: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'> = {
231
+ taskId: task.id,
232
+ authorId: 'u1',
233
+ authorType: 'agent',
234
+ content: 'First',
235
+ mentions: ['u2'],
236
+ metadata: { source: 'cli' }
237
+ };
238
+ const first = await store.addComment(input);
239
+ await sleep(2);
240
+ await store.addComment({ taskId: task.id, authorId: 'u2', content: 'Second' });
241
+
242
+ expect(await store.getComment(first.id)).toMatchObject(input);
243
+ expect((await store.getComments(task.id)).map((c) => c.content)).toEqual(['First', 'Second']);
244
+ });
245
+
246
+ it('round-trips attachments and applies every attachment filter together', async () => {
247
+ const store = await createStore();
248
+ const input: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'> = {
249
+ taskId: 't1',
250
+ projectId: 'p1',
251
+ commentId: 'c1',
252
+ uploaderId: 'u1',
253
+ uploaderType: 'user',
254
+ filename: 'spec.pdf',
255
+ mimeType: 'application/pdf',
256
+ sizeBytes: 1234,
257
+ url: 'https://example.com/spec.pdf',
258
+ storageKey: 'projects/p1/spec.pdf',
259
+ artifactType: 'spec',
260
+ metadata: { pages: 3 }
261
+ };
262
+ const created = await store.createAttachment(input);
263
+ await store.createAttachment({ ...input, commentId: 'c2' });
264
+ await store.createAttachment({ ...input, taskId: 't2' });
265
+
266
+ expect(await store.getAttachment(created.id)).toMatchObject(input);
267
+ expect(await store.getAttachments({ taskId: 't1', commentId: 'c1' })).toHaveLength(1);
268
+ expect(await store.getAttachments({ taskId: 't1' })).toHaveLength(2);
269
+ });
270
+
271
+ it('returns activities newest first and applies every activity filter together', async () => {
272
+ const store = await createStore();
273
+ await store.logActivity({ projectId: 'p1', taskId: 't1', actorId: 'u1', action: 'first', details: { n: 1 } });
274
+ await sleep(2);
275
+ await store.logActivity({ projectId: 'p1', taskId: 't2', actorId: 'u1', action: 'second', details: {} });
276
+ await sleep(2);
277
+ await store.logActivity({ projectId: 'p2', taskId: 't1', actorId: 'u1', action: 'third', details: {} });
278
+
279
+ expect((await store.getActivities({ projectId: 'p1' })).map((a) => a.action)).toEqual(['second', 'first']);
280
+ expect((await store.getActivities({ projectId: 'p1', taskId: 't1' })).map((a) => a.action)).toEqual(['first']);
281
+ expect((await store.getActivities()).map((a) => a.action)).toEqual(['third', 'second', 'first']);
282
+ expect((await store.getActivities({ taskId: 't1' }))[1]).toMatchObject({ details: { n: 1 } });
283
+ });
284
+
285
+ it('round-trips dependencies, time entries and webhooks', async () => {
286
+ const store = await createStore();
287
+ const dep = await store.addDependency({ taskId: 'a', dependsOnTaskId: 'b', type: 'blocked_by' });
288
+ expect(await store.getDependency(dep.id)).toEqual(dep);
289
+ expect(await store.getDependencies('b')).toHaveLength(1);
290
+ expect(await store.removeDependency(dep.id)).toBe(true);
291
+
292
+ const entry = await store.logTime({ taskId: 't1', userId: 'u1', hours: 1.5, isBillable: false, description: 'Work', loggedAt: '2026-10-01T10:00:00.000Z' });
293
+ expect((await store.getTimeEntries('t1'))[0]).toMatchObject({ id: entry.id, hours: 1.5, isBillable: false, description: 'Work', loggedAt: '2026-10-01T10:00:00.000Z' });
294
+ expect(await store.deleteTimeEntry(entry.id)).toBe(true);
295
+ expect(await store.getTimeEntries('t1')).toHaveLength(0);
296
+
297
+ const webhook = await store.addWebhook({ name: 'Hook', url: 'https://example.com/h', secret: 'whsec_x', events: ['*'], active: true, tenantId: 'tenant-1' });
298
+ expect(await store.getWebhook(webhook.id)).toMatchObject({ name: 'Hook', secret: 'whsec_x', events: ['*'], active: true, tenantId: 'tenant-1' });
299
+ });
300
+ });
301
+ }
@@ -0,0 +1 @@
1
+ export * from './conformance.js';
@@ -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/authorization.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/actor.test.ts","./src/engine/authorization.test.ts","./src/engine/cascade.test.ts","./src/engine/config.test.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/plugins/plugins.test.ts","./src/schemas/index.ts","./src/schemas/schemas.test.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/query.test.ts","./src/store/query.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","./src/webhooks/dispatcher.ts","./src/webhooks/index.ts","./src/webhooks/signature.ts","./src/webhooks/webhooks.test.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/authorization.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/actor.test.ts","./src/engine/authorization.test.ts","./src/engine/cascade.test.ts","./src/engine/config.test.ts","./src/engine/entity-events.test.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/plugins/plugins.test.ts","./src/schemas/index.ts","./src/schemas/schemas.test.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/conformance.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/query.test.ts","./src/store/query.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/testing/conformance.ts","./src/testing/index.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","./src/webhooks/dispatcher.ts","./src/webhooks/index.ts","./src/webhooks/signature.ts","./src/webhooks/webhooks.test.ts"],"version":"5.9.3"}