trekoon 0.3.7 → 0.3.8
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 +198 -73
- package/.agents/skills/trekoon/reference/execution-with-team.md +9 -11
- package/.agents/skills/trekoon/reference/execution.md +26 -9
- package/.agents/skills/trekoon/reference/planning.md +48 -0
- package/README.md +39 -14
- package/docs/quickstart.md +21 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +8 -25
- package/src/board/assets/state/api.js +5 -6
- package/src/board/assets/state/utils.js +50 -17
- package/src/board/routes.ts +22 -19
- package/src/board/server.ts +57 -4
- package/src/board/snapshot.ts +133 -84
- package/src/commands/board.ts +1 -1
- package/src/commands/quickstart.ts +10 -0
- package/src/domain/mutation-service.ts +179 -65
- package/src/domain/tracker-domain.ts +16 -2
- package/src/storage/migrations.ts +27 -2
- package/src/storage/schema.ts +2 -1
- package/src/sync/event-writes.ts +11 -7
- package/src/sync/service.ts +183 -4
package/src/commands/board.ts
CHANGED
|
@@ -121,7 +121,7 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
|
|
|
121
121
|
return okResult({
|
|
122
122
|
command: "board.open",
|
|
123
123
|
human: [
|
|
124
|
-
`Board ready at ${server.
|
|
124
|
+
`Board ready at ${server.fallbackUrl}`,
|
|
125
125
|
launch.launched
|
|
126
126
|
? `Browser launched with ${launch.command}`
|
|
127
127
|
: `Browser launch failed: ${launch.errorMessage ?? "unknown failure"}`,
|
|
@@ -4,6 +4,16 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
|
4
4
|
const QUICKSTART_TEXT = [
|
|
5
5
|
"Trekoon quickstart",
|
|
6
6
|
"",
|
|
7
|
+
"Human workflow:",
|
|
8
|
+
" 1. Gather context through discussion, brainstorming, or research.",
|
|
9
|
+
" 2. Run: trekoon plan <goal>",
|
|
10
|
+
" Use this when you want Trekoon to turn the goal into an execution-ready epic.",
|
|
11
|
+
" 3. Run: trekoon <epic-id>",
|
|
12
|
+
" Use this to inspect the epic, next ready work, and blockers before execution.",
|
|
13
|
+
" 4. Run: trekoon <epic-id> execute",
|
|
14
|
+
" Use this when you want the agent to keep working until the epic is done,",
|
|
15
|
+
" all remaining work is blocked, or it needs your input.",
|
|
16
|
+
"",
|
|
7
17
|
"Agents: always use --toon on every command.",
|
|
8
18
|
"Aligned with: .agents/skills/trekoon/SKILL.md",
|
|
9
19
|
"",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Database } from "bun:sqlite";
|
|
2
2
|
|
|
3
3
|
import { writeTransaction } from "../storage/database";
|
|
4
|
-
import { appendEventWithGitContext, withTransactionEventContext } from "../sync/event-writes";
|
|
4
|
+
import { appendEventWithGitContext, prepareEventWriteContext, withTransactionEventContext } from "../sync/event-writes";
|
|
5
5
|
import { ENTITY_OPERATIONS } from "./mutation-operations";
|
|
6
6
|
import { TrackerDomain, validateStatusTransition } from "./tracker-domain";
|
|
7
7
|
import {
|
|
@@ -49,6 +49,8 @@ type AtomicIdempotentMutationResult =
|
|
|
49
49
|
| AtomicIdempotencyReplayResult
|
|
50
50
|
| AtomicIdempotencyCompletedResult;
|
|
51
51
|
|
|
52
|
+
const BOARD_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
53
|
+
|
|
52
54
|
function countMatches(value: string, searchText: string): number {
|
|
53
55
|
if (searchText.length === 0) {
|
|
54
56
|
return 0;
|
|
@@ -128,7 +130,49 @@ export class MutationService {
|
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
#writeTransaction<T>(fn: () => T): T {
|
|
131
|
-
|
|
133
|
+
const eventContext = prepareEventWriteContext(this.#db, this.#cwd);
|
|
134
|
+
return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, eventContext, fn));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#dependencyEventEntityId(input: {
|
|
138
|
+
sourceId: string;
|
|
139
|
+
sourceKind: string;
|
|
140
|
+
dependsOnId: string;
|
|
141
|
+
dependsOnKind: string;
|
|
142
|
+
}): string {
|
|
143
|
+
return `${input.sourceKind}:${input.sourceId}->${input.dependsOnKind}:${input.dependsOnId}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#dependencyEventFields(input: {
|
|
147
|
+
dependencyId?: string | undefined;
|
|
148
|
+
sourceId: string;
|
|
149
|
+
sourceKind?: string | undefined;
|
|
150
|
+
dependsOnId: string;
|
|
151
|
+
dependsOnKind?: string | undefined;
|
|
152
|
+
sourceEventId?: string | undefined;
|
|
153
|
+
}): Record<string, string> {
|
|
154
|
+
const fields: Record<string, string> = {
|
|
155
|
+
source_id: input.sourceId,
|
|
156
|
+
depends_on_id: input.dependsOnId,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (input.dependencyId) {
|
|
160
|
+
fields.dependency_id = input.dependencyId;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (input.sourceKind) {
|
|
164
|
+
fields.source_kind = input.sourceKind;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (input.dependsOnKind) {
|
|
168
|
+
fields.depends_on_kind = input.dependsOnKind;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (input.sourceEventId) {
|
|
172
|
+
fields.source_event_id = input.sourceEventId;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return fields;
|
|
132
176
|
}
|
|
133
177
|
|
|
134
178
|
createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
|
|
@@ -185,12 +229,18 @@ export class MutationService {
|
|
|
185
229
|
}
|
|
186
230
|
|
|
187
231
|
for (const dependency of created.dependencies) {
|
|
188
|
-
this.#appendEntityEvent(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
232
|
+
this.#appendEntityEvent(
|
|
233
|
+
"dependency",
|
|
234
|
+
this.#dependencyEventEntityId(dependency),
|
|
235
|
+
ENTITY_OPERATIONS.dependency.added,
|
|
236
|
+
this.#dependencyEventFields({
|
|
237
|
+
dependencyId: dependency.id,
|
|
238
|
+
sourceId: dependency.sourceId,
|
|
239
|
+
sourceKind: dependency.sourceKind,
|
|
240
|
+
dependsOnId: dependency.dependsOnId,
|
|
241
|
+
dependsOnKind: dependency.dependsOnKind,
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
194
244
|
}
|
|
195
245
|
|
|
196
246
|
return {
|
|
@@ -293,12 +343,18 @@ export class MutationService {
|
|
|
293
343
|
}
|
|
294
344
|
|
|
295
345
|
for (const dependency of created.dependencies) {
|
|
296
|
-
this.#appendEntityEvent(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
346
|
+
this.#appendEntityEvent(
|
|
347
|
+
"dependency",
|
|
348
|
+
this.#dependencyEventEntityId(dependency),
|
|
349
|
+
ENTITY_OPERATIONS.dependency.added,
|
|
350
|
+
this.#dependencyEventFields({
|
|
351
|
+
dependencyId: dependency.id,
|
|
352
|
+
sourceId: dependency.sourceId,
|
|
353
|
+
sourceKind: dependency.sourceKind,
|
|
354
|
+
dependsOnId: dependency.dependsOnId,
|
|
355
|
+
dependsOnKind: dependency.dependsOnKind,
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
302
358
|
}
|
|
303
359
|
|
|
304
360
|
return created;
|
|
@@ -349,16 +405,19 @@ export class MutationService {
|
|
|
349
405
|
}
|
|
350
406
|
|
|
351
407
|
for (const dependency of plan.touchingDependencies) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
408
|
+
this.#appendEntityEvent(
|
|
409
|
+
"dependency",
|
|
410
|
+
this.#dependencyEventEntityId(dependency),
|
|
411
|
+
ENTITY_OPERATIONS.dependency.removed,
|
|
412
|
+
this.#dependencyEventFields({
|
|
413
|
+
dependencyId: dependency.id,
|
|
414
|
+
sourceId: dependency.sourceId,
|
|
415
|
+
sourceKind: dependency.sourceKind,
|
|
416
|
+
dependsOnId: dependency.dependsOnId,
|
|
417
|
+
dependsOnKind: dependency.dependsOnKind,
|
|
418
|
+
sourceEventId: taskDeleteEventId,
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
362
421
|
}
|
|
363
422
|
|
|
364
423
|
return {
|
|
@@ -454,13 +513,16 @@ export class MutationService {
|
|
|
454
513
|
for (const dependency of touchingDependencies) {
|
|
455
514
|
this.#appendEntityEvent(
|
|
456
515
|
"dependency",
|
|
457
|
-
|
|
516
|
+
this.#dependencyEventEntityId(dependency),
|
|
458
517
|
ENTITY_OPERATIONS.dependency.removed,
|
|
459
|
-
{
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
518
|
+
this.#dependencyEventFields({
|
|
519
|
+
dependencyId: dependency.id,
|
|
520
|
+
sourceId: dependency.sourceId,
|
|
521
|
+
sourceKind: dependency.sourceKind,
|
|
522
|
+
dependsOnId: dependency.dependsOnId,
|
|
523
|
+
dependsOnKind: dependency.dependsOnKind,
|
|
524
|
+
sourceEventId: subtaskDeleteEventId,
|
|
525
|
+
}),
|
|
464
526
|
);
|
|
465
527
|
}
|
|
466
528
|
return {
|
|
@@ -489,13 +551,16 @@ export class MutationService {
|
|
|
489
551
|
for (const dependency of touchingDependencies) {
|
|
490
552
|
this.#appendEntityEvent(
|
|
491
553
|
"dependency",
|
|
492
|
-
|
|
554
|
+
this.#dependencyEventEntityId(dependency),
|
|
493
555
|
ENTITY_OPERATIONS.dependency.removed,
|
|
494
|
-
{
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
556
|
+
this.#dependencyEventFields({
|
|
557
|
+
dependencyId: dependency.id,
|
|
558
|
+
sourceId: dependency.sourceId,
|
|
559
|
+
sourceKind: dependency.sourceKind,
|
|
560
|
+
dependsOnId: dependency.dependsOnId,
|
|
561
|
+
dependsOnKind: dependency.dependsOnKind,
|
|
562
|
+
sourceEventId: subtaskDeleteEventId,
|
|
563
|
+
}),
|
|
499
564
|
);
|
|
500
565
|
}
|
|
501
566
|
|
|
@@ -516,12 +581,18 @@ export class MutationService {
|
|
|
516
581
|
addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
|
|
517
582
|
return this.#writeTransaction((): DependencyRecord => {
|
|
518
583
|
const dependency = this.#domain.addDependency(sourceId, dependsOnId);
|
|
519
|
-
this.#appendEntityEvent(
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
584
|
+
this.#appendEntityEvent(
|
|
585
|
+
"dependency",
|
|
586
|
+
this.#dependencyEventEntityId(dependency),
|
|
587
|
+
ENTITY_OPERATIONS.dependency.added,
|
|
588
|
+
this.#dependencyEventFields({
|
|
589
|
+
dependencyId: dependency.id,
|
|
590
|
+
sourceId: dependency.sourceId,
|
|
591
|
+
sourceKind: dependency.sourceKind,
|
|
592
|
+
dependsOnId: dependency.dependsOnId,
|
|
593
|
+
dependsOnKind: dependency.dependsOnKind,
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
525
596
|
return dependency;
|
|
526
597
|
});
|
|
527
598
|
}
|
|
@@ -534,12 +605,18 @@ export class MutationService {
|
|
|
534
605
|
}): AtomicIdempotentMutationResult {
|
|
535
606
|
return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
|
|
536
607
|
const dependency = this.#domain.addDependency(input.sourceId, input.dependsOnId);
|
|
537
|
-
this.#appendEntityEvent(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
608
|
+
this.#appendEntityEvent(
|
|
609
|
+
"dependency",
|
|
610
|
+
this.#dependencyEventEntityId(dependency),
|
|
611
|
+
ENTITY_OPERATIONS.dependency.added,
|
|
612
|
+
this.#dependencyEventFields({
|
|
613
|
+
dependencyId: dependency.id,
|
|
614
|
+
sourceId: dependency.sourceId,
|
|
615
|
+
sourceKind: dependency.sourceKind,
|
|
616
|
+
dependsOnId: dependency.dependsOnId,
|
|
617
|
+
dependsOnKind: dependency.dependsOnKind,
|
|
618
|
+
}),
|
|
619
|
+
);
|
|
543
620
|
return {
|
|
544
621
|
state: "completed",
|
|
545
622
|
status: 201,
|
|
@@ -552,12 +629,18 @@ export class MutationService {
|
|
|
552
629
|
return this.#writeTransaction((): CompactDependencyBatchAddResult => {
|
|
553
630
|
const created = this.#domain.addDependencyBatch(input);
|
|
554
631
|
for (const dependency of created.dependencies) {
|
|
555
|
-
this.#appendEntityEvent(
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
632
|
+
this.#appendEntityEvent(
|
|
633
|
+
"dependency",
|
|
634
|
+
this.#dependencyEventEntityId(dependency),
|
|
635
|
+
ENTITY_OPERATIONS.dependency.added,
|
|
636
|
+
this.#dependencyEventFields({
|
|
637
|
+
dependencyId: dependency.id,
|
|
638
|
+
sourceId: dependency.sourceId,
|
|
639
|
+
sourceKind: dependency.sourceKind,
|
|
640
|
+
dependsOnId: dependency.dependsOnId,
|
|
641
|
+
dependsOnKind: dependency.dependsOnKind,
|
|
642
|
+
}),
|
|
643
|
+
);
|
|
561
644
|
}
|
|
562
645
|
return created;
|
|
563
646
|
});
|
|
@@ -565,12 +648,22 @@ export class MutationService {
|
|
|
565
648
|
|
|
566
649
|
removeDependency(sourceId: string, dependsOnId: string): number {
|
|
567
650
|
return this.#writeTransaction((): number => {
|
|
651
|
+
const existingDependency = this.#domain.listDependencies(sourceId)
|
|
652
|
+
.find((dependency) => dependency.dependsOnId === dependsOnId);
|
|
568
653
|
const removed = this.#domain.removeDependency(sourceId, dependsOnId);
|
|
569
654
|
if (removed > 0) {
|
|
570
|
-
this.#appendEntityEvent("dependency",
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
655
|
+
this.#appendEntityEvent("dependency", this.#dependencyEventEntityId({
|
|
656
|
+
sourceId,
|
|
657
|
+
sourceKind: existingDependency?.sourceKind ?? "task",
|
|
658
|
+
dependsOnId,
|
|
659
|
+
dependsOnKind: existingDependency?.dependsOnKind ?? "task",
|
|
660
|
+
}), ENTITY_OPERATIONS.dependency.removed, this.#dependencyEventFields({
|
|
661
|
+
dependencyId: existingDependency?.id,
|
|
662
|
+
sourceId,
|
|
663
|
+
sourceKind: existingDependency?.sourceKind,
|
|
664
|
+
dependsOnId,
|
|
665
|
+
dependsOnKind: existingDependency?.dependsOnKind,
|
|
666
|
+
}));
|
|
574
667
|
}
|
|
575
668
|
return removed;
|
|
576
669
|
});
|
|
@@ -589,9 +682,10 @@ export class MutationService {
|
|
|
589
682
|
}) => Record<string, unknown>;
|
|
590
683
|
}): AtomicIdempotentMutationResult {
|
|
591
684
|
return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
|
|
592
|
-
const
|
|
593
|
-
.filter((dependency) => dependency.dependsOnId === input.dependsOnId)
|
|
594
|
-
|
|
685
|
+
const existingDependencies = this.#domain.listDependencies(input.sourceId)
|
|
686
|
+
.filter((dependency) => dependency.dependsOnId === input.dependsOnId);
|
|
687
|
+
const existingDependencyIds = existingDependencies.map((dependency) => dependency.id);
|
|
688
|
+
const existingDependency = existingDependencies[0];
|
|
595
689
|
const removed = this.#domain.removeDependency(input.sourceId, input.dependsOnId);
|
|
596
690
|
if (removed === 0) {
|
|
597
691
|
throw new DomainError({
|
|
@@ -603,10 +697,18 @@ export class MutationService {
|
|
|
603
697
|
},
|
|
604
698
|
});
|
|
605
699
|
}
|
|
606
|
-
this.#appendEntityEvent("dependency",
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
700
|
+
this.#appendEntityEvent("dependency", this.#dependencyEventEntityId({
|
|
701
|
+
sourceId: input.sourceId,
|
|
702
|
+
sourceKind: existingDependency?.sourceKind ?? "task",
|
|
703
|
+
dependsOnId: input.dependsOnId,
|
|
704
|
+
dependsOnKind: existingDependency?.dependsOnKind ?? "task",
|
|
705
|
+
}), ENTITY_OPERATIONS.dependency.removed, this.#dependencyEventFields({
|
|
706
|
+
dependencyId: existingDependency?.id,
|
|
707
|
+
sourceId: input.sourceId,
|
|
708
|
+
sourceKind: existingDependency?.sourceKind,
|
|
709
|
+
dependsOnId: input.dependsOnId,
|
|
710
|
+
dependsOnKind: existingDependency?.dependsOnKind,
|
|
711
|
+
}));
|
|
610
712
|
return {
|
|
611
713
|
state: "completed",
|
|
612
714
|
status: 200,
|
|
@@ -741,6 +843,7 @@ export class MutationService {
|
|
|
741
843
|
mutate: () => AtomicIdempotencyCompletedResult,
|
|
742
844
|
): AtomicIdempotentMutationResult {
|
|
743
845
|
return this.#writeTransaction((): AtomicIdempotentMutationResult => {
|
|
846
|
+
this.#pruneExpiredIdempotencyKeys();
|
|
744
847
|
const inserted = this.#db.query(
|
|
745
848
|
`
|
|
746
849
|
INSERT INTO board_idempotency_keys (
|
|
@@ -816,6 +919,17 @@ export class MutationService {
|
|
|
816
919
|
});
|
|
817
920
|
}
|
|
818
921
|
|
|
922
|
+
#pruneExpiredIdempotencyKeys(now: number = Date.now()): void {
|
|
923
|
+
const cutoff: number = now - BOARD_IDEMPOTENCY_RETENTION_MS;
|
|
924
|
+
this.#db.query(
|
|
925
|
+
`
|
|
926
|
+
DELETE FROM board_idempotency_keys
|
|
927
|
+
WHERE state = 'completed'
|
|
928
|
+
AND created_at < ?;
|
|
929
|
+
`,
|
|
930
|
+
).run(cutoff);
|
|
931
|
+
}
|
|
932
|
+
|
|
819
933
|
#previewScopeReplacement(
|
|
820
934
|
nodes: readonly SearchNode[],
|
|
821
935
|
searchText: string,
|
|
@@ -165,6 +165,20 @@ function normalizeSubtaskDescription(value: string | undefined): string {
|
|
|
165
165
|
return value.trim();
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
function normalizeOwner(value: string | null | undefined): string | null | undefined {
|
|
169
|
+
if (value === undefined) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (value === null) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const normalized = value.trim()
|
|
178
|
+
;
|
|
179
|
+
return normalized.length > 0 ? normalized : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
168
182
|
function isValidStatus(status: string): status is ValidStatus {
|
|
169
183
|
return (VALID_STATUSES as readonly string[]).includes(status);
|
|
170
184
|
}
|
|
@@ -526,7 +540,7 @@ export class TrackerDomain {
|
|
|
526
540
|
const nextDescription: string =
|
|
527
541
|
input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
|
|
528
542
|
const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
|
|
529
|
-
const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
|
|
543
|
+
const nextOwner: string | null = input.owner !== undefined ? normalizeOwner(input.owner) ?? null : existing.owner;
|
|
530
544
|
this.assertNoUnresolvedDependenciesForStatusTransition(id, "task", existing.status, nextStatus);
|
|
531
545
|
const now: number = Date.now();
|
|
532
546
|
|
|
@@ -777,7 +791,7 @@ export class TrackerDomain {
|
|
|
777
791
|
const nextDescription: string =
|
|
778
792
|
input.description !== undefined ? normalizeSubtaskDescription(input.description) : existing.description;
|
|
779
793
|
const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
|
|
780
|
-
const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
|
|
794
|
+
const nextOwner: string | null = input.owner !== undefined ? normalizeOwner(input.owner) ?? null : existing.owner;
|
|
781
795
|
this.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
|
|
782
796
|
const now: number = Date.now();
|
|
783
797
|
|
|
@@ -105,6 +105,14 @@ const BOARD_IDEMPOTENCY_MIGRATION_DOWN_STATEMENTS: readonly string[] = [
|
|
|
105
105
|
"DROP TABLE IF EXISTS board_idempotency_keys;",
|
|
106
106
|
];
|
|
107
107
|
|
|
108
|
+
const BOARD_IDEMPOTENCY_RETENTION_INDEX_UP_STATEMENTS: readonly string[] = [
|
|
109
|
+
"CREATE INDEX IF NOT EXISTS idx_board_idempotency_state_created_at ON board_idempotency_keys(state, created_at);",
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const BOARD_IDEMPOTENCY_RETENTION_INDEX_DOWN_STATEMENTS: readonly string[] = [
|
|
113
|
+
"DROP INDEX IF EXISTS idx_board_idempotency_state_created_at;",
|
|
114
|
+
];
|
|
115
|
+
|
|
108
116
|
function tableHasColumn(db: Database, tableName: string, columnName: string): boolean {
|
|
109
117
|
const columns = db.query(`PRAGMA table_info(${tableName});`).all() as Array<{ name: string }>;
|
|
110
118
|
return columns.some((column) => column.name === columnName);
|
|
@@ -359,6 +367,20 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
359
367
|
}
|
|
360
368
|
},
|
|
361
369
|
},
|
|
370
|
+
{
|
|
371
|
+
version: 10,
|
|
372
|
+
name: "0010_board_idempotency_retention_index",
|
|
373
|
+
up(db: Database): void {
|
|
374
|
+
for (const statement of BOARD_IDEMPOTENCY_RETENTION_INDEX_UP_STATEMENTS) {
|
|
375
|
+
db.exec(statement);
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
down(db: Database): void {
|
|
379
|
+
for (const statement of BOARD_IDEMPOTENCY_RETENTION_INDEX_DOWN_STATEMENTS) {
|
|
380
|
+
db.exec(statement);
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
},
|
|
362
384
|
];
|
|
363
385
|
|
|
364
386
|
function migrationTableExists(db: Database): boolean {
|
|
@@ -557,6 +579,11 @@ export function migrateDatabase(db: Database): void {
|
|
|
557
579
|
ensureMigrationTable(db);
|
|
558
580
|
ensureMigrationVersionColumn(db);
|
|
559
581
|
|
|
582
|
+
// Backfill the legacy board_idempotency_keys.state column before running
|
|
583
|
+
// any migrations so that later migrations (e.g. 0010's state-scoped index)
|
|
584
|
+
// can assume the column exists on databases whose 0009 predates it.
|
|
585
|
+
migrateBoardIdempotencyState(db);
|
|
586
|
+
|
|
560
587
|
const version: number = currentVersion(db);
|
|
561
588
|
|
|
562
589
|
for (const migration of MIGRATIONS) {
|
|
@@ -567,8 +594,6 @@ export function migrateDatabase(db: Database): void {
|
|
|
567
594
|
migration.up(db);
|
|
568
595
|
recordMigration(db, migration);
|
|
569
596
|
}
|
|
570
|
-
|
|
571
|
-
migrateBoardIdempotencyState(db);
|
|
572
597
|
});
|
|
573
598
|
}
|
|
574
599
|
|
package/src/storage/schema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const SCHEMA_VERSION =
|
|
1
|
+
export const SCHEMA_VERSION = 3;
|
|
2
2
|
|
|
3
3
|
export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
|
|
4
4
|
`PRAGMA foreign_keys = ON;`,
|
|
@@ -137,4 +137,5 @@ export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
|
|
|
137
137
|
`CREATE INDEX IF NOT EXISTS idx_conflicts_resolution ON sync_conflicts(resolution);`,
|
|
138
138
|
`CREATE INDEX IF NOT EXISTS idx_conflicts_resolution_entity_field_id ON sync_conflicts(resolution, entity_id, field_name, id);`,
|
|
139
139
|
`CREATE INDEX IF NOT EXISTS idx_board_idempotency_created_at ON board_idempotency_keys(created_at);`,
|
|
140
|
+
`CREATE INDEX IF NOT EXISTS idx_board_idempotency_state_created_at ON board_idempotency_keys(state, created_at);`,
|
|
140
141
|
];
|
package/src/sync/event-writes.ts
CHANGED
|
@@ -38,18 +38,22 @@ export function nextEventTimestamp(db: Database): number {
|
|
|
38
38
|
return Math.max(now, latestEvent.created_at + 1);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function
|
|
42
|
-
const existingContext: EventWriteContext | undefined = transactionEventContexts.get(db);
|
|
43
|
-
if (existingContext) {
|
|
44
|
-
return fn();
|
|
45
|
-
}
|
|
46
|
-
|
|
41
|
+
export function prepareEventWriteContext(db: Database, cwd: string): EventWriteContext {
|
|
47
42
|
const nextTimestamp: number = nextEventTimestamp(db);
|
|
48
43
|
const git: ResolvedGitContext = resolveGitContext(cwd, nextTimestamp);
|
|
49
|
-
|
|
44
|
+
|
|
45
|
+
return {
|
|
50
46
|
git,
|
|
51
47
|
nextTimestamp,
|
|
52
48
|
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function withTransactionEventContext<T>(db: Database, context: EventWriteContext, fn: () => T): T {
|
|
52
|
+
const existingContext: EventWriteContext | undefined = transactionEventContexts.get(db);
|
|
53
|
+
if (existingContext) {
|
|
54
|
+
return fn();
|
|
55
|
+
}
|
|
56
|
+
|
|
53
57
|
transactionEventContexts.set(db, context);
|
|
54
58
|
|
|
55
59
|
try {
|