feat(FN-4908): complete Step 1 — add SQL reliability aggregations
Fusion-Task-Id: FN-4908 Fusion-Task-Lineage: 97d96ea4-8618-43cc-8448-0132cd57974e
This commit is contained in:
committed by
gsxdsm
parent
b1d1221ba6
commit
3037bbf9d6
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
import type { TaskStore } from "../store.js";
|
||||
|
||||
describe("TaskStore reliability aggregations", () => {
|
||||
const harness = createTaskStoreTestHarness();
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.beforeEach();
|
||||
store = harness.store();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.afterEach();
|
||||
});
|
||||
|
||||
const insertActivity = (entry: {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: string;
|
||||
taskId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => {
|
||||
(store as any).db
|
||||
.prepare(
|
||||
`INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
entry.id,
|
||||
entry.timestamp,
|
||||
entry.type,
|
||||
entry.taskId ?? null,
|
||||
null,
|
||||
"test",
|
||||
entry.metadata ? JSON.stringify(entry.metadata) : null,
|
||||
);
|
||||
};
|
||||
|
||||
it("returns empty results when no rows match", async () => {
|
||||
const counts = await store.getTaskMovedCountsByDay({
|
||||
since: "2026-05-10T00:00:00.000Z",
|
||||
until: "2026-05-20T00:00:00.000Z",
|
||||
toColumn: "in-review",
|
||||
});
|
||||
const durationEvents = await store.getInReviewDurationEvents({
|
||||
since: "2026-05-10T00:00:00.000Z",
|
||||
until: "2026-05-20T00:00:00.000Z",
|
||||
});
|
||||
const mergedTaskIds = await store.getTaskMergedTaskIds({
|
||||
since: "2026-05-10T00:00:00.000Z",
|
||||
until: "2026-05-20T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(counts).toEqual({});
|
||||
expect(durationEvents).toEqual([]);
|
||||
expect(mergedTaskIds).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("aggregates task:moved rows by day with from/to filters", async () => {
|
||||
insertActivity({
|
||||
id: "a1",
|
||||
timestamp: "2026-05-16T10:00:00.000Z",
|
||||
type: "task:moved",
|
||||
taskId: "FN-1",
|
||||
metadata: { from: "todo", to: "in-review" },
|
||||
});
|
||||
insertActivity({
|
||||
id: "a2",
|
||||
timestamp: "2026-05-16T12:00:00.000Z",
|
||||
type: "task:moved",
|
||||
taskId: "FN-2",
|
||||
metadata: { from: "todo", to: "in-review" },
|
||||
});
|
||||
insertActivity({
|
||||
id: "a3",
|
||||
timestamp: "2026-05-17T09:00:00.000Z",
|
||||
type: "task:moved",
|
||||
taskId: "FN-3",
|
||||
metadata: { from: "in-review", to: "in-progress" },
|
||||
});
|
||||
|
||||
const entered = await store.getTaskMovedCountsByDay({
|
||||
since: "2026-05-15T00:00:00.000Z",
|
||||
until: "2026-05-18T00:00:00.000Z",
|
||||
toColumn: "in-review",
|
||||
});
|
||||
const bounced = await store.getTaskMovedCountsByDay({
|
||||
since: "2026-05-15T00:00:00.000Z",
|
||||
until: "2026-05-18T00:00:00.000Z",
|
||||
fromColumn: "in-review",
|
||||
toColumn: "in-progress",
|
||||
});
|
||||
|
||||
expect(entered).toEqual({ "2026-05-16": 2 });
|
||||
expect(bounced).toEqual({ "2026-05-17": 1 });
|
||||
});
|
||||
|
||||
it("uses strict since and inclusive until boundaries", async () => {
|
||||
insertActivity({
|
||||
id: "b1",
|
||||
timestamp: "2026-05-16T00:00:00.000Z",
|
||||
type: "task:moved",
|
||||
taskId: "FN-1",
|
||||
metadata: { from: "todo", to: "in-review" },
|
||||
});
|
||||
insertActivity({
|
||||
id: "b2",
|
||||
timestamp: "2026-05-16T00:00:00.001Z",
|
||||
type: "task:moved",
|
||||
taskId: "FN-2",
|
||||
metadata: { from: "todo", to: "in-review" },
|
||||
});
|
||||
|
||||
const counts = await store.getTaskMovedCountsByDay({
|
||||
since: "2026-05-16T00:00:00.000Z",
|
||||
until: "2026-05-16T00:00:00.001Z",
|
||||
toColumn: "in-review",
|
||||
});
|
||||
|
||||
expect(counts).toEqual({ "2026-05-16": 1 });
|
||||
});
|
||||
|
||||
it("returns focused in-review duration event set ordered ascending", async () => {
|
||||
insertActivity({
|
||||
id: "d1",
|
||||
timestamp: "2026-05-16T10:00:00.000Z",
|
||||
type: "task:moved",
|
||||
taskId: "FN-1",
|
||||
metadata: { from: "todo", to: "in-review" },
|
||||
});
|
||||
insertActivity({
|
||||
id: "d2",
|
||||
timestamp: "2026-05-16T11:00:00.000Z",
|
||||
type: "task:moved",
|
||||
taskId: "FN-1",
|
||||
metadata: { from: "in-review", to: "done" },
|
||||
});
|
||||
insertActivity({
|
||||
id: "d3",
|
||||
timestamp: "2026-05-16T12:00:00.000Z",
|
||||
type: "task:moved",
|
||||
taskId: "FN-1",
|
||||
metadata: { from: "in-review", to: "in-progress" },
|
||||
});
|
||||
|
||||
const events = await store.getInReviewDurationEvents({
|
||||
since: "2026-05-16T09:00:00.000Z",
|
||||
until: "2026-05-16T13:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(events.map((event) => event.id)).toEqual(["d1", "d2"]);
|
||||
});
|
||||
|
||||
it("returns distinct merged task ids in window", async () => {
|
||||
insertActivity({ id: "m1", timestamp: "2026-05-16T10:00:00.000Z", type: "task:merged", taskId: "FN-1" });
|
||||
insertActivity({ id: "m2", timestamp: "2026-05-16T11:00:00.000Z", type: "task:merged", taskId: "FN-1" });
|
||||
insertActivity({ id: "m3", timestamp: "2026-05-16T12:00:00.000Z", type: "task:merged", taskId: "FN-2" });
|
||||
|
||||
const mergedTaskIds = await store.getTaskMergedTaskIds({
|
||||
since: "2026-05-16T09:00:00.000Z",
|
||||
until: "2026-05-16T12:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(mergedTaskIds).toEqual(new Set(["FN-1", "FN-2"]));
|
||||
});
|
||||
|
||||
it("aggregates correctly with 60k+ rows", async () => {
|
||||
const insert = (store as any).db.prepare(
|
||||
`INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata)
|
||||
VALUES (?, ?, 'task:moved', ?, NULL, 'bulk', ?)`,
|
||||
);
|
||||
|
||||
for (let i = 0; i < 60_100; i += 1) {
|
||||
const day = i < 100 ? "2026-05-15" : "2026-05-16";
|
||||
insert.run(`bulk-${i}`, `${day}T12:00:00.000Z`, `FN-${i}`, JSON.stringify({ from: "todo", to: "in-review" }));
|
||||
}
|
||||
|
||||
const counts = await store.getTaskMovedCountsByDay({
|
||||
since: "2026-05-14T00:00:00.000Z",
|
||||
until: "2026-05-17T00:00:00.000Z",
|
||||
toColumn: "in-review",
|
||||
});
|
||||
|
||||
expect(counts).toEqual({
|
||||
"2026-05-15": 100,
|
||||
"2026-05-16": 60_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8546,6 +8546,80 @@ ${notificationsSection}`;
|
||||
}));
|
||||
}
|
||||
|
||||
async getTaskMovedCountsByDay(options: {
|
||||
since: string;
|
||||
until: string;
|
||||
fromColumn?: string;
|
||||
toColumn?: string;
|
||||
}): Promise<Record<string, number>> {
|
||||
let sql =
|
||||
"SELECT substr(timestamp, 1, 10) AS day, COUNT(*) AS count FROM activityLog WHERE type = 'task:moved' AND timestamp > ? AND timestamp <= ?";
|
||||
const params: (string | number)[] = [options.since, options.until];
|
||||
|
||||
if (options.fromColumn) {
|
||||
sql += " AND json_extract(metadata, '$.from') = ?";
|
||||
params.push(options.fromColumn);
|
||||
}
|
||||
|
||||
if (options.toColumn) {
|
||||
sql += " AND json_extract(metadata, '$.to') = ?";
|
||||
params.push(options.toColumn);
|
||||
}
|
||||
|
||||
sql += " GROUP BY substr(timestamp, 1, 10)";
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params) as Array<{ day: string; count: number }>;
|
||||
const countsByDay: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
countsByDay[row.day] = row.count;
|
||||
}
|
||||
return countsByDay;
|
||||
}
|
||||
|
||||
async getInReviewDurationEvents(options: { since: string; until: string }): Promise<ActivityLogEntry[]> {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM activityLog
|
||||
WHERE type = 'task:moved'
|
||||
AND timestamp > ?
|
||||
AND timestamp <= ?
|
||||
AND (
|
||||
json_extract(metadata, '$.to') = 'in-review'
|
||||
OR (
|
||||
json_extract(metadata, '$.from') = 'in-review'
|
||||
AND json_extract(metadata, '$.to') = 'done'
|
||||
)
|
||||
)
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(options.since, options.until, 200_000) as unknown as ActivityLogRow[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
timestamp: row.timestamp,
|
||||
type: row.type as ActivityEventType,
|
||||
taskId: row.taskId || undefined,
|
||||
taskTitle: row.taskTitle || undefined,
|
||||
details: row.details,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async getTaskMergedTaskIds(options: { since: string; until: string }): Promise<Set<string>> {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT DISTINCT taskId FROM activityLog
|
||||
WHERE type = 'task:merged'
|
||||
AND timestamp > ?
|
||||
AND timestamp <= ?
|
||||
AND taskId IS NOT NULL`,
|
||||
)
|
||||
.all(options.since, options.until) as Array<{ taskId: string }>;
|
||||
|
||||
return new Set(rows.map((row) => row.taskId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all activity log entries.
|
||||
* Use with caution - this permanently deletes activity history.
|
||||
|
||||
Reference in New Issue
Block a user