trekoon 0.1.2 → 0.1.5
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/.agents/skills/trekoon/SKILL.md +165 -7
- package/README.md +5 -3
- package/package.json +3 -2
- package/src/commands/events.ts +88 -0
- package/src/commands/help.ts +6 -1
- package/src/commands/migrate.ts +123 -0
- package/src/commands/quickstart.ts +1 -1
- package/src/commands/subtask.ts +225 -3
- package/src/domain/tracker-domain.ts +18 -37
- package/src/runtime/cli-shell.ts +8 -0
- package/src/storage/database.ts +11 -2
- package/src/storage/events-retention.ts +138 -0
- package/src/storage/migrations.ts +340 -19
- package/src/storage/schema.ts +1 -0
- package/src/storage/types.ts +1 -0
- package/src/sync/service.ts +9 -1
package/src/commands/subtask.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseArgs, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
|
|
1
|
+
import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
|
|
2
2
|
|
|
3
3
|
import { DomainError, type SubtaskRecord } from "../domain/types";
|
|
4
4
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
@@ -12,6 +12,62 @@ function formatSubtask(subtask: SubtaskRecord): string {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const VIEW_MODES = ["table", "compact"] as const;
|
|
15
|
+
const DEFAULT_SUBTASK_LIST_LIMIT = 10;
|
|
16
|
+
const DEFAULT_OPEN_SUBTASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
|
|
17
|
+
|
|
18
|
+
function parseIdsOption(rawIds: string | undefined): string[] {
|
|
19
|
+
if (rawIds === undefined) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return rawIds
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((value) => value.trim())
|
|
26
|
+
.filter((value) => value.length > 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
|
|
30
|
+
if (rawStatuses === undefined) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return rawStatuses
|
|
35
|
+
.split(",")
|
|
36
|
+
.map((value) => value.trim())
|
|
37
|
+
.filter((value) => value.length > 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function subtaskStatusPriority(status: string): number {
|
|
41
|
+
if (status === "in_progress" || status === "in-progress") {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (status === "todo") {
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return 2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function filterSortAndLimitSubtasks(
|
|
53
|
+
subtasks: readonly SubtaskRecord[],
|
|
54
|
+
statuses: readonly string[] | undefined,
|
|
55
|
+
limit: number | undefined,
|
|
56
|
+
): SubtaskRecord[] {
|
|
57
|
+
const allowedStatuses = statuses === undefined ? undefined : new Set(statuses);
|
|
58
|
+
const filtered = allowedStatuses === undefined ? [...subtasks] : subtasks.filter((subtask) => allowedStatuses.has(subtask.status));
|
|
59
|
+
const sorted = [...filtered].sort((left, right) => subtaskStatusPriority(left.status) - subtaskStatusPriority(right.status));
|
|
60
|
+
|
|
61
|
+
if (limit === undefined) {
|
|
62
|
+
return sorted;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return sorted.slice(0, limit);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function appendLine(existing: string, line: string): string {
|
|
69
|
+
return existing.length > 0 ? `${existing}\n${line}` : line;
|
|
70
|
+
}
|
|
15
71
|
|
|
16
72
|
function formatSubtaskListTable(subtasks: readonly SubtaskRecord[]): string {
|
|
17
73
|
return formatHumanTable(
|
|
@@ -101,6 +157,8 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
101
157
|
case "list": {
|
|
102
158
|
const missingListOption =
|
|
103
159
|
readMissingOptionValue(parsed.missingOptionValues, "view") ??
|
|
160
|
+
readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
|
|
161
|
+
readMissingOptionValue(parsed.missingOptionValues, "limit", "l") ??
|
|
104
162
|
readMissingOptionValue(parsed.missingOptionValues, "task", "t");
|
|
105
163
|
if (missingListOption !== undefined) {
|
|
106
164
|
return failMissingOptionValue("subtask.list", missingListOption);
|
|
@@ -108,6 +166,10 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
108
166
|
|
|
109
167
|
const rawView: string | undefined = readOption(parsed.options, "view");
|
|
110
168
|
const view = readEnumOption(parsed.options, VIEW_MODES, "view");
|
|
169
|
+
const includeAll = hasFlag(parsed.flags, "all");
|
|
170
|
+
const rawStatuses = readOption(parsed.options, "status", "s");
|
|
171
|
+
const rawLimit = readOption(parsed.options, "limit", "l");
|
|
172
|
+
|
|
111
173
|
if (rawView !== undefined && view === undefined) {
|
|
112
174
|
return failResult({
|
|
113
175
|
command: "subtask.list",
|
|
@@ -120,8 +182,64 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
120
182
|
});
|
|
121
183
|
}
|
|
122
184
|
|
|
185
|
+
if (includeAll && rawStatuses !== undefined) {
|
|
186
|
+
return failResult({
|
|
187
|
+
command: "subtask.list",
|
|
188
|
+
human: "Use either --all or --status, not both.",
|
|
189
|
+
data: { code: "invalid_input", flags: ["all", "status"] },
|
|
190
|
+
error: {
|
|
191
|
+
code: "invalid_input",
|
|
192
|
+
message: "--all and --status are mutually exclusive",
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (includeAll && rawLimit !== undefined) {
|
|
198
|
+
return failResult({
|
|
199
|
+
command: "subtask.list",
|
|
200
|
+
human: "Use either --all or --limit, not both.",
|
|
201
|
+
data: { code: "invalid_input", flags: ["all", "limit"] },
|
|
202
|
+
error: {
|
|
203
|
+
code: "invalid_input",
|
|
204
|
+
message: "--all and --limit are mutually exclusive",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const statuses = parseStatusCsv(rawStatuses);
|
|
210
|
+
if (rawStatuses !== undefined && statuses !== undefined && statuses.length === 0) {
|
|
211
|
+
return failResult({
|
|
212
|
+
command: "subtask.list",
|
|
213
|
+
human: "Provide at least one status with --status.",
|
|
214
|
+
data: { code: "invalid_input", status: rawStatuses },
|
|
215
|
+
error: {
|
|
216
|
+
code: "invalid_input",
|
|
217
|
+
message: "Invalid --status value",
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const parsedLimit = parseStrictPositiveInt(rawLimit);
|
|
223
|
+
if (Number.isNaN(parsedLimit)) {
|
|
224
|
+
return failResult({
|
|
225
|
+
command: "subtask.list",
|
|
226
|
+
human: "Invalid --limit value. Use an integer >= 1.",
|
|
227
|
+
data: { code: "invalid_input", limit: rawLimit },
|
|
228
|
+
error: {
|
|
229
|
+
code: "invalid_input",
|
|
230
|
+
message: "Invalid --limit value",
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
123
235
|
const taskId: string | undefined = readOption(parsed.options, "task", "t") ?? parsed.positional[1];
|
|
124
|
-
const
|
|
236
|
+
const selectedStatuses = includeAll
|
|
237
|
+
? undefined
|
|
238
|
+
: statuses ?? [...DEFAULT_OPEN_SUBTASK_STATUSES];
|
|
239
|
+
const selectedLimit = includeAll
|
|
240
|
+
? undefined
|
|
241
|
+
: parsedLimit ?? DEFAULT_SUBTASK_LIST_LIMIT;
|
|
242
|
+
const subtasks = filterSortAndLimitSubtasks(domain.listSubtasks(taskId), selectedStatuses, selectedLimit);
|
|
125
243
|
const listView = view ?? "table";
|
|
126
244
|
const human =
|
|
127
245
|
subtasks.length === 0
|
|
@@ -138,6 +256,8 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
138
256
|
}
|
|
139
257
|
case "update": {
|
|
140
258
|
const missingUpdateOption =
|
|
259
|
+
readMissingOptionValue(parsed.missingOptionValues, "ids") ??
|
|
260
|
+
readMissingOptionValue(parsed.missingOptionValues, "append") ??
|
|
141
261
|
readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
|
|
142
262
|
readMissingOptionValue(parsed.missingOptionValues, "status", "s");
|
|
143
263
|
if (missingUpdateOption !== undefined) {
|
|
@@ -145,10 +265,112 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
145
265
|
}
|
|
146
266
|
|
|
147
267
|
const subtaskId: string = parsed.positional[1] ?? "";
|
|
268
|
+
const updateAll: boolean = hasFlag(parsed.flags, "all");
|
|
269
|
+
const rawIds: string | undefined = readOption(parsed.options, "ids");
|
|
270
|
+
const ids = parseIdsOption(rawIds);
|
|
148
271
|
const title: string | undefined = readOption(parsed.options, "title");
|
|
149
272
|
const description: string | undefined = readOption(parsed.options, "description", "d");
|
|
273
|
+
const append: string | undefined = readOption(parsed.options, "append");
|
|
150
274
|
const status: string | undefined = readOption(parsed.options, "status", "s");
|
|
151
|
-
|
|
275
|
+
|
|
276
|
+
if (updateAll && ids.length > 0) {
|
|
277
|
+
return failResult({
|
|
278
|
+
command: "subtask.update",
|
|
279
|
+
human: "Use either --all or --ids, not both.",
|
|
280
|
+
data: { code: "invalid_input", target: ["all", "ids"] },
|
|
281
|
+
error: {
|
|
282
|
+
code: "invalid_input",
|
|
283
|
+
message: "--all and --ids are mutually exclusive",
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (append !== undefined && description !== undefined) {
|
|
289
|
+
return failResult({
|
|
290
|
+
command: "subtask.update",
|
|
291
|
+
human: "Use either --append or --description, not both.",
|
|
292
|
+
data: { code: "invalid_input", fields: ["append", "description"] },
|
|
293
|
+
error: {
|
|
294
|
+
code: "invalid_input",
|
|
295
|
+
message: "--append and --description are mutually exclusive",
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const hasBulkTarget = updateAll || ids.length > 0;
|
|
301
|
+
if (hasBulkTarget) {
|
|
302
|
+
if (subtaskId.length > 0) {
|
|
303
|
+
return failResult({
|
|
304
|
+
command: "subtask.update",
|
|
305
|
+
human: "Do not pass a subtask id when using --all or --ids.",
|
|
306
|
+
data: { code: "invalid_input", id: subtaskId },
|
|
307
|
+
error: {
|
|
308
|
+
code: "invalid_input",
|
|
309
|
+
message: "Positional id is not allowed with --all/--ids",
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (title !== undefined || description !== undefined) {
|
|
315
|
+
return failResult({
|
|
316
|
+
command: "subtask.update",
|
|
317
|
+
human: "Bulk update supports only --append and/or --status.",
|
|
318
|
+
data: { code: "invalid_input" },
|
|
319
|
+
error: {
|
|
320
|
+
code: "invalid_input",
|
|
321
|
+
message: "Bulk update supports only --append and --status",
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (append === undefined && status === undefined) {
|
|
327
|
+
return failResult({
|
|
328
|
+
command: "subtask.update",
|
|
329
|
+
human: "Bulk update requires --append and/or --status.",
|
|
330
|
+
data: { code: "invalid_input" },
|
|
331
|
+
error: {
|
|
332
|
+
code: "invalid_input",
|
|
333
|
+
message: "Missing bulk update fields",
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const targets = updateAll ? [...domain.listSubtasks()] : ids.map((id) => domain.getSubtaskOrThrow(id));
|
|
339
|
+
const subtasks = targets.map((target) =>
|
|
340
|
+
domain.updateSubtask(target.id, {
|
|
341
|
+
status,
|
|
342
|
+
description: append === undefined ? undefined : appendLine(target.description, append),
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return okResult({
|
|
347
|
+
command: "subtask.update",
|
|
348
|
+
human: `Updated ${subtasks.length} subtask(s)`,
|
|
349
|
+
data: {
|
|
350
|
+
subtasks,
|
|
351
|
+
target: updateAll ? "all" : "ids",
|
|
352
|
+
ids: subtasks.map((subtask) => subtask.id),
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (subtaskId.length === 0) {
|
|
358
|
+
return failResult({
|
|
359
|
+
command: "subtask.update",
|
|
360
|
+
human: "Provide a subtask id, or use --all/--ids for bulk update.",
|
|
361
|
+
data: { code: "invalid_input" },
|
|
362
|
+
error: {
|
|
363
|
+
code: "invalid_input",
|
|
364
|
+
message: "Missing subtask id",
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const nextDescription =
|
|
370
|
+
append === undefined
|
|
371
|
+
? description
|
|
372
|
+
: appendLine(domain.getSubtaskOrThrow(subtaskId).description, append);
|
|
373
|
+
const subtask = domain.updateSubtask(subtaskId, { title, description: nextDescription, status });
|
|
152
374
|
|
|
153
375
|
return okResult({
|
|
154
376
|
command: "subtask.update",
|
|
@@ -522,44 +522,25 @@ export class TrackerDomain {
|
|
|
522
522
|
}
|
|
523
523
|
|
|
524
524
|
private wouldCreateCycle(sourceId: string, dependsOnId: string): boolean {
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const queue: string[] = [dependsOnId];
|
|
543
|
-
|
|
544
|
-
while (queue.length > 0) {
|
|
545
|
-
const next = queue.shift();
|
|
546
|
-
if (!next) {
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
if (next === sourceId) {
|
|
550
|
-
return true;
|
|
551
|
-
}
|
|
552
|
-
if (visited.has(next)) {
|
|
553
|
-
continue;
|
|
554
|
-
}
|
|
555
|
-
visited.add(next);
|
|
556
|
-
const outgoing = adjacency.get(next) ?? [];
|
|
557
|
-
for (const neighbor of outgoing) {
|
|
558
|
-
queue.push(neighbor);
|
|
559
|
-
}
|
|
560
|
-
}
|
|
525
|
+
const row = this.#db
|
|
526
|
+
.query(
|
|
527
|
+
`
|
|
528
|
+
WITH RECURSIVE reachable(id) AS (
|
|
529
|
+
SELECT ?
|
|
530
|
+
UNION
|
|
531
|
+
SELECT d.depends_on_id
|
|
532
|
+
FROM dependencies d
|
|
533
|
+
INNER JOIN reachable r ON d.source_id = r.id
|
|
534
|
+
)
|
|
535
|
+
SELECT 1 AS has_cycle
|
|
536
|
+
FROM reachable
|
|
537
|
+
WHERE id = ?
|
|
538
|
+
LIMIT 1;
|
|
539
|
+
`,
|
|
540
|
+
)
|
|
541
|
+
.get(dependsOnId, sourceId) as { has_cycle: number } | null;
|
|
561
542
|
|
|
562
|
-
return
|
|
543
|
+
return row !== null;
|
|
563
544
|
}
|
|
564
545
|
}
|
|
565
546
|
|
package/src/runtime/cli-shell.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { runHelp } from "../commands/help";
|
|
2
2
|
import { runDep } from "../commands/dep";
|
|
3
3
|
import { runEpic } from "../commands/epic";
|
|
4
|
+
import { runEvents } from "../commands/events";
|
|
4
5
|
import { runInit } from "../commands/init";
|
|
6
|
+
import { runMigrate } from "../commands/migrate";
|
|
5
7
|
import { runQuickstart } from "../commands/quickstart";
|
|
6
8
|
import { runSkills } from "../commands/skills";
|
|
7
9
|
import { runSubtask } from "../commands/subtask";
|
|
@@ -21,6 +23,8 @@ const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
|
|
|
21
23
|
"task",
|
|
22
24
|
"subtask",
|
|
23
25
|
"dep",
|
|
26
|
+
"events",
|
|
27
|
+
"migrate",
|
|
24
28
|
"sync",
|
|
25
29
|
"skills",
|
|
26
30
|
"wipe",
|
|
@@ -147,6 +151,10 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
|
|
|
147
151
|
return runSubtask(context);
|
|
148
152
|
case "dep":
|
|
149
153
|
return runDep(context);
|
|
154
|
+
case "events":
|
|
155
|
+
return runEvents(context);
|
|
156
|
+
case "migrate":
|
|
157
|
+
return runMigrate(context);
|
|
150
158
|
case "sync":
|
|
151
159
|
return runSync(context);
|
|
152
160
|
case "skills":
|
package/src/storage/database.ts
CHANGED
|
@@ -11,7 +11,14 @@ export interface TrekoonDatabase {
|
|
|
11
11
|
close(): void;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export
|
|
14
|
+
export interface OpenTrekoonDatabaseOptions {
|
|
15
|
+
readonly autoMigrate?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function openTrekoonDatabase(
|
|
19
|
+
workingDirectory: string = process.cwd(),
|
|
20
|
+
options: OpenTrekoonDatabaseOptions = {},
|
|
21
|
+
): TrekoonDatabase {
|
|
15
22
|
const paths: StoragePaths = resolveStoragePaths(workingDirectory);
|
|
16
23
|
|
|
17
24
|
mkdirSync(paths.storageDir, { recursive: true });
|
|
@@ -22,7 +29,9 @@ export function openTrekoonDatabase(workingDirectory: string = process.cwd()): T
|
|
|
22
29
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
23
30
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
24
31
|
|
|
25
|
-
|
|
32
|
+
if (options.autoMigrate ?? true) {
|
|
33
|
+
migrateDatabase(db);
|
|
34
|
+
}
|
|
26
35
|
|
|
27
36
|
return {
|
|
28
37
|
db,
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { type Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_EVENT_RETENTION_DAYS = 90;
|
|
4
|
+
const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
export interface EventPruneOptions {
|
|
7
|
+
readonly retentionDays?: number;
|
|
8
|
+
readonly dryRun?: boolean;
|
|
9
|
+
readonly archive?: boolean;
|
|
10
|
+
readonly now?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface EventPruneSummary {
|
|
14
|
+
readonly retentionDays: number;
|
|
15
|
+
readonly cutoffTimestamp: number;
|
|
16
|
+
readonly dryRun: boolean;
|
|
17
|
+
readonly archive: boolean;
|
|
18
|
+
readonly candidateCount: number;
|
|
19
|
+
readonly archivedCount: number;
|
|
20
|
+
readonly deletedCount: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ensureArchiveTable(db: Database): void {
|
|
24
|
+
db.exec(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS event_archive (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
entity_kind TEXT NOT NULL,
|
|
28
|
+
entity_id TEXT NOT NULL,
|
|
29
|
+
operation TEXT NOT NULL,
|
|
30
|
+
payload TEXT NOT NULL,
|
|
31
|
+
git_branch TEXT,
|
|
32
|
+
git_head TEXT,
|
|
33
|
+
created_at INTEGER NOT NULL,
|
|
34
|
+
updated_at INTEGER NOT NULL,
|
|
35
|
+
version INTEGER NOT NULL DEFAULT 1
|
|
36
|
+
);
|
|
37
|
+
`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertRetentionDays(value: number): number {
|
|
41
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
42
|
+
throw new Error("retentionDays must be a positive integer.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function countCandidates(db: Database, cutoffTimestamp: number): number {
|
|
49
|
+
const row = db.query("SELECT COUNT(*) AS count FROM events WHERE created_at < ?;").get(cutoffTimestamp) as
|
|
50
|
+
| { count: number }
|
|
51
|
+
| null;
|
|
52
|
+
|
|
53
|
+
return row?.count ?? 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function pruneEvents(db: Database, options: EventPruneOptions = {}): EventPruneSummary {
|
|
57
|
+
const retentionDays: number = assertRetentionDays(options.retentionDays ?? DEFAULT_EVENT_RETENTION_DAYS);
|
|
58
|
+
const dryRun: boolean = options.dryRun ?? false;
|
|
59
|
+
const archive: boolean = options.archive ?? false;
|
|
60
|
+
const now: number = options.now ?? Date.now();
|
|
61
|
+
const cutoffTimestamp: number = now - retentionDays * DAY_IN_MILLISECONDS;
|
|
62
|
+
const candidateCount: number = countCandidates(db, cutoffTimestamp);
|
|
63
|
+
|
|
64
|
+
if (dryRun || candidateCount === 0) {
|
|
65
|
+
return {
|
|
66
|
+
retentionDays,
|
|
67
|
+
cutoffTimestamp,
|
|
68
|
+
dryRun,
|
|
69
|
+
archive,
|
|
70
|
+
candidateCount,
|
|
71
|
+
archivedCount: 0,
|
|
72
|
+
deletedCount: 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return db.transaction((): EventPruneSummary => {
|
|
77
|
+
let archivedCount = 0;
|
|
78
|
+
|
|
79
|
+
if (archive) {
|
|
80
|
+
ensureArchiveTable(db);
|
|
81
|
+
const archived = db
|
|
82
|
+
.query(
|
|
83
|
+
`
|
|
84
|
+
INSERT INTO event_archive (
|
|
85
|
+
id,
|
|
86
|
+
entity_kind,
|
|
87
|
+
entity_id,
|
|
88
|
+
operation,
|
|
89
|
+
payload,
|
|
90
|
+
git_branch,
|
|
91
|
+
git_head,
|
|
92
|
+
created_at,
|
|
93
|
+
updated_at,
|
|
94
|
+
version
|
|
95
|
+
)
|
|
96
|
+
SELECT
|
|
97
|
+
id,
|
|
98
|
+
entity_kind,
|
|
99
|
+
entity_id,
|
|
100
|
+
operation,
|
|
101
|
+
payload,
|
|
102
|
+
git_branch,
|
|
103
|
+
git_head,
|
|
104
|
+
created_at,
|
|
105
|
+
updated_at,
|
|
106
|
+
version
|
|
107
|
+
FROM events
|
|
108
|
+
WHERE created_at < ?
|
|
109
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
110
|
+
entity_kind = excluded.entity_kind,
|
|
111
|
+
entity_id = excluded.entity_id,
|
|
112
|
+
operation = excluded.operation,
|
|
113
|
+
payload = excluded.payload,
|
|
114
|
+
git_branch = excluded.git_branch,
|
|
115
|
+
git_head = excluded.git_head,
|
|
116
|
+
created_at = excluded.created_at,
|
|
117
|
+
updated_at = excluded.updated_at,
|
|
118
|
+
version = excluded.version;
|
|
119
|
+
`,
|
|
120
|
+
)
|
|
121
|
+
.run(cutoffTimestamp);
|
|
122
|
+
|
|
123
|
+
archivedCount = archived.changes;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const deleted = db.query("DELETE FROM events WHERE created_at < ?;").run(cutoffTimestamp);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
retentionDays,
|
|
130
|
+
cutoffTimestamp,
|
|
131
|
+
dryRun,
|
|
132
|
+
archive,
|
|
133
|
+
candidateCount,
|
|
134
|
+
archivedCount,
|
|
135
|
+
deletedCount: deleted.changes,
|
|
136
|
+
};
|
|
137
|
+
})();
|
|
138
|
+
}
|