@critical-path/core 0.32.0 → 0.33.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 +10 -0
- package/README.md +11 -0
- package/dist/store/conformance.test.d.ts +2 -0
- package/dist/store/conformance.test.d.ts.map +1 -0
- package/dist/store/conformance.test.js +15 -0
- package/dist/store/conformance.test.js.map +1 -0
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +14 -3
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/sqlite.d.ts +6 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +46 -5
- package/dist/store/sqlite.js.map +1 -1
- package/dist/testing/conformance.d.ts +36 -0
- package/dist/testing/conformance.d.ts.map +1 -0
- package/dist/testing/conformance.js +244 -0
- package/dist/testing/conformance.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +5 -1
- package/src/store/conformance.test.ts +15 -0
- package/src/store/firebase.ts +17 -3
- package/src/store/sqlite.ts +54 -5
- package/src/testing/conformance.ts +301 -0
- package/src/testing/index.ts +1 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/store/sqlite.ts
CHANGED
|
@@ -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');
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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,7 +1159,7 @@ 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
|
});
|
|
@@ -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
|
|
|
@@ -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';
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -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/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"}
|