trekoon 0.3.4 → 0.3.6
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 +42 -1
- package/docs/commands.md +11 -3
- package/docs/machine-contracts.md +173 -0
- package/docs/quickstart.md +7 -2
- package/package.json +1 -1
- package/src/commands/help.ts +11 -2
- package/src/commands/init.ts +36 -0
- package/src/commands/quickstart.ts +8 -2
- package/src/commands/sync.ts +101 -16
- package/src/sync/service.ts +231 -24
- package/src/sync/types.ts +24 -0
|
@@ -171,7 +171,10 @@ trekoon --toon --compact session
|
|
|
171
171
|
2. **`behind > 0`?** → Sync first: `trekoon --toon sync pull --from main`.
|
|
172
172
|
This pulls tracker events (not git commits) so task states are current.
|
|
173
173
|
3. **`pendingConflicts > 0`?** → Resolve before claiming work:
|
|
174
|
-
`trekoon --toon sync conflicts list`.
|
|
174
|
+
`trekoon --toon sync conflicts list`. For uniform conflicts, batch resolve:
|
|
175
|
+
`trekoon --toon sync resolve --all --use ours` (or `--use theirs`). For
|
|
176
|
+
mixed conflicts, inspect individually with `sync conflicts show <id>` and
|
|
177
|
+
resolve per-conflict.
|
|
175
178
|
4. **Session returned a next task?** → Proceed to step 2 (claim work).
|
|
176
179
|
5. **No next task and unsure what to do?** → Run `trekoon --toon suggest` for
|
|
177
180
|
priority-ranked recommendations (see step 1b below).
|
|
@@ -556,6 +559,44 @@ field `status`:
|
|
|
556
559
|
Always inspect conflicts with `sync conflicts show` before resolving. Choosing
|
|
557
560
|
`theirs` without inspection can overwrite in-progress work in the shared DB.
|
|
558
561
|
|
|
562
|
+
### Understanding why conflicts happen
|
|
563
|
+
|
|
564
|
+
| Scenario | Typical resolution | Why |
|
|
565
|
+
|---|---|---|
|
|
566
|
+
| Completed work vs stale main state | ours | Your branch has the latest progress |
|
|
567
|
+
| Enriched descriptions vs original | ours | Your descriptions are more detailed |
|
|
568
|
+
| Upstream updates from another agent | theirs | Accept the newer upstream state |
|
|
569
|
+
| User-intentional reset | theirs | Respect the user's explicit action |
|
|
570
|
+
|
|
571
|
+
### Agent decision framework
|
|
572
|
+
|
|
573
|
+
1. List conflicts: `trekoon --toon sync conflicts list`
|
|
574
|
+
2. Group by pattern — are conflicts on the same field or direction?
|
|
575
|
+
3. If uniform pattern, batch resolve: `trekoon --toon sync resolve --all --use ours`
|
|
576
|
+
4. If mixed, narrow by entity or field, or inspect individually
|
|
577
|
+
5. When unsure, ask the user
|
|
578
|
+
|
|
579
|
+
### Batch resolve patterns
|
|
580
|
+
|
|
581
|
+
Common scenarios:
|
|
582
|
+
|
|
583
|
+
```bash
|
|
584
|
+
# Resolve all conflicts at once (most common after completing work)
|
|
585
|
+
trekoon --toon sync resolve --all --use ours
|
|
586
|
+
|
|
587
|
+
# Preview before resolving
|
|
588
|
+
trekoon --toon sync resolve --all --use ours --dry-run
|
|
589
|
+
|
|
590
|
+
# Narrow to status field conflicts only
|
|
591
|
+
trekoon --toon sync resolve --all --use ours --field status
|
|
592
|
+
|
|
593
|
+
# Narrow to a specific entity
|
|
594
|
+
trekoon --toon sync resolve --all --use theirs --entity <id>
|
|
595
|
+
|
|
596
|
+
# Combine filters
|
|
597
|
+
trekoon --toon sync resolve --all --use ours --entity <id> --field description
|
|
598
|
+
```
|
|
599
|
+
|
|
559
600
|
## Shared-database model
|
|
560
601
|
|
|
561
602
|
Trekoon uses **one live SQLite database per repository**. The file lives at
|
package/docs/commands.md
CHANGED
|
@@ -20,6 +20,7 @@ the quickest way to get started, read [Quickstart](quickstart.md) first.
|
|
|
20
20
|
- `trekoon sync status [--from <branch>]`
|
|
21
21
|
- `trekoon sync pull --from <branch>`
|
|
22
22
|
- `trekoon sync resolve <conflict-id> --use ours|theirs [--dry-run]`
|
|
23
|
+
- `trekoon sync resolve --all --use ours|theirs [--entity <id>] [--field <name>] [--dry-run]`
|
|
23
24
|
- `trekoon sync conflicts <list|show> [--mode pending|all]`
|
|
24
25
|
- `trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]`
|
|
25
26
|
- `trekoon skills install -g|--global [--editor opencode|claude|pi]`
|
|
@@ -244,14 +245,21 @@ conflicts when the same field was modified on both sides. `--from` is required.
|
|
|
244
245
|
|
|
245
246
|
```bash
|
|
246
247
|
trekoon --toon sync resolve <conflict-id> --use ours|theirs [--dry-run]
|
|
248
|
+
trekoon --toon sync resolve --all --use ours|theirs [--entity <id>] [--field <name>] [--dry-run]
|
|
247
249
|
```
|
|
248
250
|
|
|
249
251
|
Resolves a pending conflict. `--use ours` keeps the current DB value.
|
|
250
|
-
`--use theirs`
|
|
252
|
+
`--use theirs` applies the source-branch value (or deletes the entity for
|
|
253
|
+
delete conflicts).
|
|
251
254
|
|
|
252
255
|
- `--dry-run` previews the resolution without mutating the database
|
|
253
|
-
- In human mode, `--use theirs` shows a 30-second confirmation prompt
|
|
254
|
-
|
|
256
|
+
- In human mode, `--use theirs` shows a 30-second confirmation prompt. Single-
|
|
257
|
+
conflict prompts include the field, current value, and incoming value.
|
|
258
|
+
- In human mode, batch `sync resolve --all --use theirs` shows a count-only
|
|
259
|
+
confirmation prompt (`Resolve N conflict(s) using ...? [y/N]`). `--use ours`
|
|
260
|
+
does not prompt. Toon mode skips all confirmation prompts.
|
|
261
|
+
- `--all` batch-resolves all pending conflicts. `--entity` and `--field` narrow
|
|
262
|
+
the batch. `--all` and a positional conflict ID are mutually exclusive.
|
|
255
263
|
|
|
256
264
|
### `sync conflicts`
|
|
257
265
|
|
|
@@ -365,6 +365,179 @@ data:
|
|
|
365
365
|
|
|
366
366
|
No mutation occurs. The conflict stays pending.
|
|
367
367
|
|
|
368
|
+
## Sync batch resolve
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
trekoon --toon sync resolve --all --use ours|theirs [--entity <id>] [--field <name>]
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
```text
|
|
375
|
+
ok: true
|
|
376
|
+
command: sync.resolve
|
|
377
|
+
data:
|
|
378
|
+
resolution: ours|theirs
|
|
379
|
+
resolvedCount: <number>
|
|
380
|
+
resolvedIds: [<conflict-id>, ...]
|
|
381
|
+
filters:
|
|
382
|
+
entity: <entity-id> | null
|
|
383
|
+
field: <field-name> | null
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Human-mode note: `sync resolve --all --use theirs` asks for confirmation before
|
|
387
|
+
execution. Cancellation returns `error.code: cancelled` with the requested
|
|
388
|
+
`resolution`, `cancelled: true`, and the normalized `filters`.
|
|
389
|
+
|
|
390
|
+
When confirmation is required, execution is bound to the previewed conflict ID
|
|
391
|
+
set. If another process resolves one of those conflicts before the confirmed
|
|
392
|
+
write happens, the command fails with `error.code: conflict_set_changed`
|
|
393
|
+
instead of partially resolving a drifted batch.
|
|
394
|
+
|
|
395
|
+
## Sync batch resolve dry-run
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
trekoon --toon sync resolve --all --use ours|theirs [--entity <id>] [--field <name>] --dry-run
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
```text
|
|
402
|
+
ok: true
|
|
403
|
+
command: sync.resolve
|
|
404
|
+
data:
|
|
405
|
+
resolution: ours|theirs
|
|
406
|
+
matchedCount: <number>
|
|
407
|
+
matchedIds: [<conflict-id>, ...]
|
|
408
|
+
filters:
|
|
409
|
+
entity: <entity-id> | null
|
|
410
|
+
field: <field-name> | null
|
|
411
|
+
dryRun: true
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
No mutation occurs. Returns `no_matching_conflicts` error when no pending
|
|
415
|
+
conflicts match the filters.
|
|
416
|
+
|
|
417
|
+
## Sync resolve hardening errors
|
|
418
|
+
|
|
419
|
+
Recent `sync.resolve` hardening added explicit machine-visible failure modes for
|
|
420
|
+
race conditions and invalid persisted conflict targets.
|
|
421
|
+
|
|
422
|
+
### Single resolve — cancelled
|
|
423
|
+
|
|
424
|
+
Returned in human mode when the user rejects or times out a confirmation prompt.
|
|
425
|
+
Single-conflict prompts only appear for `--use theirs`.
|
|
426
|
+
|
|
427
|
+
```text
|
|
428
|
+
ok: false
|
|
429
|
+
command: sync.resolve
|
|
430
|
+
data:
|
|
431
|
+
conflictId: <conflict-id>
|
|
432
|
+
resolution: ours|theirs
|
|
433
|
+
cancelled: true
|
|
434
|
+
error:
|
|
435
|
+
code: cancelled
|
|
436
|
+
message: "Resolution cancelled by user."
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Batch resolve — cancelled
|
|
440
|
+
|
|
441
|
+
Returned in human mode when the user rejects or times out the batch prompt.
|
|
442
|
+
|
|
443
|
+
```text
|
|
444
|
+
ok: false
|
|
445
|
+
command: sync.resolve
|
|
446
|
+
data:
|
|
447
|
+
resolution: ours|theirs
|
|
448
|
+
cancelled: true
|
|
449
|
+
filters:
|
|
450
|
+
entity: <entity-id> | null
|
|
451
|
+
field: <field-name> | null
|
|
452
|
+
error:
|
|
453
|
+
code: cancelled
|
|
454
|
+
message: "Batch resolution cancelled by user."
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### Single resolve — already_resolved
|
|
458
|
+
|
|
459
|
+
Returned when a conflict is still pending at preview time but another process
|
|
460
|
+
resolves it before the confirmed write happens.
|
|
461
|
+
|
|
462
|
+
```text
|
|
463
|
+
ok: false
|
|
464
|
+
command: sync.resolve
|
|
465
|
+
data:
|
|
466
|
+
conflictId: <conflict-id>
|
|
467
|
+
resolution: ours|theirs
|
|
468
|
+
reason: already_resolved
|
|
469
|
+
error:
|
|
470
|
+
code: already_resolved
|
|
471
|
+
message: "Conflict '<conflict-id>' already resolved."
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### Resolve write hardening errors
|
|
475
|
+
|
|
476
|
+
These surface as domain failures when persisted conflict metadata no longer maps
|
|
477
|
+
to a valid writable target.
|
|
478
|
+
|
|
479
|
+
```text
|
|
480
|
+
ok: false
|
|
481
|
+
command: sync.resolve
|
|
482
|
+
data:
|
|
483
|
+
reason: unsupported_entity_kind | disallowed_field | row_not_found
|
|
484
|
+
...details
|
|
485
|
+
error:
|
|
486
|
+
code: unsupported_entity_kind | disallowed_field | row_not_found
|
|
487
|
+
message: <stable human-readable message>
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Per-code details:
|
|
491
|
+
|
|
492
|
+
- `unsupported_entity_kind`
|
|
493
|
+
- `data.entityKind`
|
|
494
|
+
- `disallowed_field`
|
|
495
|
+
- `data.tableName`
|
|
496
|
+
- `data.fieldName`
|
|
497
|
+
- `row_not_found`
|
|
498
|
+
- `data.tableName`
|
|
499
|
+
- `data.entityKind`
|
|
500
|
+
- `data.entityId`
|
|
501
|
+
|
|
502
|
+
## Sync batch resolve — no_matching_conflicts error
|
|
503
|
+
|
|
504
|
+
Applies to both the execute and dry-run variants of `sync resolve --all`.
|
|
505
|
+
Returned when the given filters match zero pending conflicts.
|
|
506
|
+
|
|
507
|
+
```text
|
|
508
|
+
ok: false
|
|
509
|
+
command: sync.resolve
|
|
510
|
+
data:
|
|
511
|
+
filters:
|
|
512
|
+
entity: <entity-id> | null
|
|
513
|
+
field: <field-name> | null
|
|
514
|
+
reason: no_matching_conflicts
|
|
515
|
+
error:
|
|
516
|
+
code: no_matching_conflicts
|
|
517
|
+
message: "No pending conflicts match the given filters."
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
## Sync batch resolve — conflict_set_changed error
|
|
521
|
+
|
|
522
|
+
Returned in human mode when batch confirmation was based on one pending conflict
|
|
523
|
+
set but one or more of those conflicts were resolved before the confirmed write
|
|
524
|
+
was applied.
|
|
525
|
+
|
|
526
|
+
```text
|
|
527
|
+
ok: false
|
|
528
|
+
command: sync.resolve
|
|
529
|
+
data:
|
|
530
|
+
filters:
|
|
531
|
+
entity: <entity-id> | null
|
|
532
|
+
field: <field-name> | null
|
|
533
|
+
expectedConflictIds: [<conflict-id>, ...]
|
|
534
|
+
availableConflictIds: [<conflict-id>, ...]
|
|
535
|
+
reason: conflict_set_changed
|
|
536
|
+
error:
|
|
537
|
+
code: conflict_set_changed
|
|
538
|
+
message: "Pending conflicts changed before batch resolution could be applied."
|
|
539
|
+
```
|
|
540
|
+
|
|
368
541
|
## Related docs
|
|
369
542
|
|
|
370
543
|
- [Quickstart](quickstart.md)
|
package/docs/quickstart.md
CHANGED
|
@@ -166,12 +166,17 @@ trekoon --toon sync conflicts list
|
|
|
166
166
|
trekoon --toon sync conflicts show <id>
|
|
167
167
|
trekoon --toon sync resolve <id> --use theirs --dry-run
|
|
168
168
|
trekoon --toon sync resolve <id> --use ours|theirs
|
|
169
|
+
trekoon --toon sync resolve --all --use ours # batch: all pending at once
|
|
169
170
|
trekoon --toon sync status
|
|
170
171
|
```
|
|
171
172
|
|
|
172
173
|
Always run `sync conflicts show` before resolving so you know what you're
|
|
173
|
-
overwriting.
|
|
174
|
-
|
|
174
|
+
overwriting. For uniform conflicts, `--all` resolves every pending conflict in
|
|
175
|
+
one command. Optional `--entity <id>` and `--field <name>` narrow the batch.
|
|
176
|
+
In human mode, `--use theirs` prompts for both single-conflict and batch
|
|
177
|
+
resolve. Single-conflict prompts include field/value details; batch prompts use
|
|
178
|
+
a count-only confirmation. All prompts time out after 30 seconds and default to
|
|
179
|
+
rejection. Toon mode skips prompts.
|
|
175
180
|
|
|
176
181
|
## What to read next
|
|
177
182
|
|
package/package.json
CHANGED
package/src/commands/help.ts
CHANGED
|
@@ -300,11 +300,17 @@ const SYNC_HELP = [
|
|
|
300
300
|
" Show full details for one conflict.",
|
|
301
301
|
" resolve <conflict-id> --use ours|theirs [--dry-run]",
|
|
302
302
|
" Resolve a conflict. --dry-run previews without writing.",
|
|
303
|
+
" resolve --all --use ours|theirs [--entity <id>] [--field <name>] [--dry-run]",
|
|
304
|
+
" Batch-resolve all pending conflicts matching filters. In human mode,",
|
|
305
|
+
" batch --use theirs prompts for confirmation before execution.",
|
|
303
306
|
"",
|
|
304
307
|
"Ours vs theirs:",
|
|
305
|
-
" Conflicts are field-level (e.g., status, title, description on one entity).",
|
|
308
|
+
" Conflicts are usually field-level (e.g., status, title, description on one entity).",
|
|
306
309
|
" --use ours Keep the current DB value. Nothing is written.",
|
|
307
|
-
" --use theirs
|
|
310
|
+
" --use theirs Apply the source-branch value (or delete the entity for delete conflicts).",
|
|
311
|
+
" Human prompts only apply to --use theirs.",
|
|
312
|
+
" Single-conflict prompts show field/value details; batch prompts show counts.",
|
|
313
|
+
" Batch human prompts apply only to --use theirs and only show the match count.",
|
|
308
314
|
"",
|
|
309
315
|
" Example: epic abc123, field: status",
|
|
310
316
|
" ours (current DB): in_progress",
|
|
@@ -326,6 +332,9 @@ const SYNC_HELP = [
|
|
|
326
332
|
" trekoon sync conflicts list --mode all",
|
|
327
333
|
" trekoon sync conflicts show <conflict-id>",
|
|
328
334
|
" trekoon sync resolve <conflict-id> --use ours",
|
|
335
|
+
" trekoon sync resolve --all --use ours",
|
|
336
|
+
" trekoon sync resolve --all --use ours --field status",
|
|
337
|
+
" trekoon sync resolve --all --use ours --entity <id> --dry-run",
|
|
329
338
|
].join("\n");
|
|
330
339
|
|
|
331
340
|
const SESSION_HELP = [
|
package/src/commands/init.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
1
4
|
import { unexpectedFailureResult } from "./error-utils";
|
|
2
5
|
|
|
3
6
|
import { ensureBoardInstalled } from "../board/install";
|
|
@@ -6,6 +9,11 @@ import { DomainError } from "../domain/types";
|
|
|
6
9
|
import { failResult, okResult } from "../io/output";
|
|
7
10
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
8
11
|
import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
|
|
12
|
+
import { type StorageMode } from "../storage/path";
|
|
13
|
+
|
|
14
|
+
type GitignoreAction = "created" | "already_exists" | "skipped";
|
|
15
|
+
|
|
16
|
+
const GITIGNORE_CONTENT = "*\n";
|
|
9
17
|
|
|
10
18
|
function buildRecoverySummary(database: TrekoonDatabase): string[] {
|
|
11
19
|
const diagnostics = database.diagnostics;
|
|
@@ -76,6 +84,24 @@ function recoveryFailureResult(error: DomainError): CliResult | null {
|
|
|
76
84
|
});
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
function ensureGitignore(storageDir: string, storageMode: StorageMode): GitignoreAction {
|
|
88
|
+
if (storageMode === "cwd") {
|
|
89
|
+
return "skipped";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const gitignorePath: string = resolve(storageDir, ".gitignore");
|
|
93
|
+
|
|
94
|
+
if (existsSync(gitignorePath)) {
|
|
95
|
+
const existing: string = readFileSync(gitignorePath, "utf8");
|
|
96
|
+
if (existing === GITIGNORE_CONTENT) {
|
|
97
|
+
return "already_exists";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
writeFileSync(gitignorePath, GITIGNORE_CONTENT, "utf8");
|
|
102
|
+
return "created";
|
|
103
|
+
}
|
|
104
|
+
|
|
79
105
|
export async function runInit(context: CliContext): Promise<CliResult> {
|
|
80
106
|
let database: TrekoonDatabase | undefined;
|
|
81
107
|
|
|
@@ -87,6 +113,11 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
87
113
|
workingDirectory: context.cwd,
|
|
88
114
|
...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
|
|
89
115
|
});
|
|
116
|
+
const gitignoreAction: GitignoreAction = ensureGitignore(
|
|
117
|
+
database.paths.storageDir,
|
|
118
|
+
diagnostics.storageMode,
|
|
119
|
+
);
|
|
120
|
+
|
|
90
121
|
const humanLines: string[] = [
|
|
91
122
|
"Trekoon initialized.",
|
|
92
123
|
`Storage mode: ${diagnostics.storageMode}`,
|
|
@@ -96,6 +127,7 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
96
127
|
`Database file: ${database.paths.databaseFile}`,
|
|
97
128
|
`Board assets: ${board.action}`,
|
|
98
129
|
`Board runtime root: ${board.paths.runtimeRoot}`,
|
|
130
|
+
`Gitignore: ${gitignoreAction}`,
|
|
99
131
|
...buildRecoverySummary(database),
|
|
100
132
|
];
|
|
101
133
|
|
|
@@ -115,6 +147,10 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
115
147
|
paths: board.paths,
|
|
116
148
|
manifest: board.manifest,
|
|
117
149
|
},
|
|
150
|
+
gitignore: {
|
|
151
|
+
action: gitignoreAction,
|
|
152
|
+
path: resolve(database.paths.storageDir, ".gitignore"),
|
|
153
|
+
},
|
|
118
154
|
legacyStateDetected: diagnostics.legacyStateDetected,
|
|
119
155
|
recoveryRequired: diagnostics.recoveryRequired,
|
|
120
156
|
recoveryStatus: diagnostics.recoveryStatus,
|
|
@@ -63,9 +63,14 @@ const QUICKSTART_TEXT = [
|
|
|
63
63
|
" trekoon --toon sync conflicts show <id>",
|
|
64
64
|
" trekoon --toon sync resolve <id> --use theirs --dry-run",
|
|
65
65
|
" trekoon --toon sync resolve <id> --use ours|theirs",
|
|
66
|
+
" trekoon --toon sync resolve --all --use ours # batch: all pending at once",
|
|
66
67
|
" trekoon --toon sync status",
|
|
67
|
-
" Always inspect conflicts before resolving.
|
|
68
|
-
"
|
|
68
|
+
" Always inspect conflicts before resolving. For uniform conflicts, --all",
|
|
69
|
+
" resolves every pending conflict in one command. Optional --entity <id>",
|
|
70
|
+
" and --field <name> narrow the batch. In human mode, --use theirs prompts",
|
|
71
|
+
" for both single-conflict and batch resolve. Single-conflict prompts show",
|
|
72
|
+
" field/value details; batch prompts use a count-only confirmation (30s",
|
|
73
|
+
" timeout, defaults to reject).",
|
|
69
74
|
"",
|
|
70
75
|
"8) Wipe (destructive recovery only)",
|
|
71
76
|
" trekoon wipe --yes",
|
|
@@ -107,6 +112,7 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
|
|
|
107
112
|
"trekoon --toon sync conflicts show <id>",
|
|
108
113
|
"trekoon --toon sync resolve <id> --use theirs --dry-run",
|
|
109
114
|
"trekoon --toon sync resolve <id> --use ours|theirs",
|
|
115
|
+
"trekoon --toon sync resolve --all --use ours",
|
|
110
116
|
"trekoon --toon sync status",
|
|
111
117
|
],
|
|
112
118
|
executionLoop: [
|
package/src/commands/sync.ts
CHANGED
|
@@ -8,12 +8,12 @@ import { failResult, okResult } from "../io/output";
|
|
|
8
8
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
9
9
|
import { resolveStorageResolutionDiagnostics } from "../storage/database";
|
|
10
10
|
import { assertValidSourceRef } from "../sync/branch-db";
|
|
11
|
-
import { getSyncConflict, listSyncConflicts, syncPull, syncResolve, syncResolvePreview, syncStatus } from "../sync/service";
|
|
11
|
+
import { getSyncConflict, listSyncConflicts, syncPull, syncResolve, syncResolveAll, syncResolveAllPreview, syncResolvePreview, syncStatus } from "../sync/service";
|
|
12
12
|
import { type ResolvePreviewSummary, type SyncResolution } from "../sync/types";
|
|
13
13
|
|
|
14
14
|
const STATUS_OPTIONS = ["from"] as const;
|
|
15
15
|
const PULL_OPTIONS = ["from"] as const;
|
|
16
|
-
const RESOLVE_OPTIONS = ["use", "dry-run"] as const;
|
|
16
|
+
const RESOLVE_OPTIONS = ["use", "dry-run", "all", "entity", "field"] as const;
|
|
17
17
|
const CONFLICTS_LIST_OPTIONS = ["mode"] as const;
|
|
18
18
|
const CONFLICTS_SHOW_OPTIONS: readonly string[] = [];
|
|
19
19
|
|
|
@@ -238,15 +238,46 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
const conflictId: string | undefined = parsed.positional[1];
|
|
241
|
+
const batchAll: boolean = hasFlag(parsed.flags, "all");
|
|
242
|
+
const resolveUsage: string = batchAll
|
|
243
|
+
? "sync resolve --all requires --use ours|theirs."
|
|
244
|
+
: "sync resolve requires <conflict-id> --use ours|theirs.";
|
|
245
|
+
|
|
241
246
|
const missingResolutionOption = readMissingOptionValue(parsed.missingOptionValues, "use");
|
|
242
247
|
if (missingResolutionOption !== undefined) {
|
|
243
|
-
return usage(
|
|
248
|
+
return usage(resolveUsage, "sync.resolve");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const missingEntityOption = readMissingOptionValue(parsed.missingOptionValues, "entity");
|
|
252
|
+
if (missingEntityOption !== undefined) {
|
|
253
|
+
return usage("sync resolve --entity requires a value.", "sync.resolve");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const missingFieldOption = readMissingOptionValue(parsed.missingOptionValues, "field");
|
|
257
|
+
if (missingFieldOption !== undefined) {
|
|
258
|
+
return usage("sync resolve --field requires a value.", "sync.resolve");
|
|
244
259
|
}
|
|
245
260
|
|
|
246
261
|
const rawResolution: string | undefined = readOption(parsed.options, "use");
|
|
247
262
|
|
|
248
|
-
if (
|
|
249
|
-
return usage("sync resolve
|
|
263
|
+
if (batchAll && conflictId) {
|
|
264
|
+
return usage("sync resolve --all cannot be combined with a positional conflict ID.", "sync.resolve");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!batchAll && !conflictId) {
|
|
268
|
+
return usage("sync resolve requires <conflict-id> or --all.", "sync.resolve");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!batchAll && readOption(parsed.options, "entity") !== undefined) {
|
|
272
|
+
return usage("sync resolve --entity is only supported with --all.", "sync.resolve");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!batchAll && readOption(parsed.options, "field") !== undefined) {
|
|
276
|
+
return usage("sync resolve --field is only supported with --all.", "sync.resolve");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!rawResolution) {
|
|
280
|
+
return usage(resolveUsage, "sync.resolve");
|
|
250
281
|
}
|
|
251
282
|
|
|
252
283
|
if (rawResolution !== "ours" && rawResolution !== "theirs") {
|
|
@@ -256,8 +287,62 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
256
287
|
const resolution: SyncResolution = rawResolution;
|
|
257
288
|
const dryRun: boolean = hasFlag(parsed.flags, "dry-run");
|
|
258
289
|
|
|
290
|
+
// --- Batch resolve (--all) ---
|
|
291
|
+
if (batchAll) {
|
|
292
|
+
const entityFilter: string | undefined = readOption(parsed.options, "entity");
|
|
293
|
+
const fieldFilter: string | undefined = readOption(parsed.options, "field");
|
|
294
|
+
const filters: { entityId?: string; fieldName?: string } = {};
|
|
295
|
+
if (entityFilter !== undefined) filters.entityId = entityFilter;
|
|
296
|
+
if (fieldFilter !== undefined) filters.fieldName = fieldFilter;
|
|
297
|
+
|
|
298
|
+
if (dryRun) {
|
|
299
|
+
const preview = syncResolveAllPreview(context.cwd, resolution, filters);
|
|
300
|
+
|
|
301
|
+
return okResult({
|
|
302
|
+
command: "sync.resolve",
|
|
303
|
+
human: `[dry-run] Would resolve ${preview.matchedCount} conflict(s) using ${preview.resolution}.`,
|
|
304
|
+
data: preview,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (resolution === "theirs" && context.mode === "human") {
|
|
309
|
+
const preview = syncResolveAllPreview(context.cwd, resolution, filters);
|
|
310
|
+
const confirmed = await promptConfirmation(
|
|
311
|
+
`Resolve ${preview.matchedCount} conflict(s) using ${resolution}? [y/N] `,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
if (!confirmed) {
|
|
315
|
+
return failResult({
|
|
316
|
+
command: "sync.resolve",
|
|
317
|
+
human: "Batch resolution cancelled by user.",
|
|
318
|
+
data: { resolution, cancelled: true, filters: preview.filters },
|
|
319
|
+
error: { code: "cancelled", message: "Batch resolution cancelled by user." },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const summary = syncResolveAll(context.cwd, resolution, filters, {
|
|
324
|
+
expectedConflictIds: preview.matchedIds,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
return okResult({
|
|
328
|
+
command: "sync.resolve",
|
|
329
|
+
human: `Resolved ${summary.resolvedCount} conflict(s) using ${summary.resolution}.`,
|
|
330
|
+
data: summary,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const summary = syncResolveAll(context.cwd, resolution, filters);
|
|
335
|
+
|
|
336
|
+
return okResult({
|
|
337
|
+
command: "sync.resolve",
|
|
338
|
+
human: `Resolved ${summary.resolvedCount} conflict(s) using ${summary.resolution}.`,
|
|
339
|
+
data: summary,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// --- Single resolve (positional conflict ID) ---
|
|
259
344
|
if (dryRun) {
|
|
260
|
-
const preview = syncResolvePreview(context.cwd, conflictId
|
|
345
|
+
const preview = syncResolvePreview(context.cwd, conflictId!, resolution);
|
|
261
346
|
|
|
262
347
|
return okResult({
|
|
263
348
|
command: "sync.resolve",
|
|
@@ -275,8 +360,8 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
275
360
|
});
|
|
276
361
|
}
|
|
277
362
|
|
|
278
|
-
if (resolution === "theirs" && context.mode
|
|
279
|
-
const preview = syncResolvePreview(context.cwd, conflictId
|
|
363
|
+
if (resolution === "theirs" && context.mode === "human") {
|
|
364
|
+
const preview = syncResolvePreview(context.cwd, conflictId!, resolution);
|
|
280
365
|
const confirmed = await promptConfirmation(formatTheirsConfirmation(preview));
|
|
281
366
|
|
|
282
367
|
if (!confirmed) {
|
|
@@ -298,7 +383,7 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
298
383
|
|
|
299
384
|
let summary;
|
|
300
385
|
try {
|
|
301
|
-
summary = syncResolve(context.cwd, conflictId
|
|
386
|
+
summary = syncResolve(context.cwd, conflictId!, resolution);
|
|
302
387
|
} catch (resolveError: unknown) {
|
|
303
388
|
if (resolveError instanceof Error && resolveError.message.includes("already resolved")) {
|
|
304
389
|
return failResult({
|
|
@@ -321,7 +406,7 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
321
406
|
if (subcommand === "conflicts") {
|
|
322
407
|
const conflictsCommand: string | undefined = parsed.positional[1];
|
|
323
408
|
if (!conflictsCommand) {
|
|
324
|
-
|
|
409
|
+
return usage("sync conflicts requires list|show.", "sync.conflicts");
|
|
325
410
|
}
|
|
326
411
|
|
|
327
412
|
if (conflictsCommand === "list") {
|
|
@@ -332,12 +417,12 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
332
417
|
|
|
333
418
|
const missingModeOption = readMissingOptionValue(parsed.missingOptionValues, "mode");
|
|
334
419
|
if (missingModeOption !== undefined) {
|
|
335
|
-
|
|
420
|
+
return usage("sync conflicts list --mode only accepts pending|all.", "sync.conflicts.list");
|
|
336
421
|
}
|
|
337
422
|
|
|
338
423
|
const mode = readOption(parsed.options, "mode") ?? "pending";
|
|
339
424
|
if (mode !== "pending" && mode !== "all") {
|
|
340
|
-
|
|
425
|
+
return usage("sync conflicts list --mode only accepts pending|all.", "sync.conflicts.list");
|
|
341
426
|
}
|
|
342
427
|
|
|
343
428
|
const conflicts = listSyncConflicts(context.cwd, mode);
|
|
@@ -359,9 +444,9 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
359
444
|
}
|
|
360
445
|
|
|
361
446
|
const conflictId: string | undefined = parsed.positional[2];
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
447
|
+
if (!conflictId) {
|
|
448
|
+
return usage("sync conflicts show requires <conflict-id>.", "sync.conflicts.show");
|
|
449
|
+
}
|
|
365
450
|
|
|
366
451
|
const conflict = getSyncConflict(context.cwd, conflictId);
|
|
367
452
|
|
|
@@ -381,7 +466,7 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
381
466
|
});
|
|
382
467
|
}
|
|
383
468
|
|
|
384
|
-
|
|
469
|
+
return usage(`Unknown sync conflicts subcommand '${conflictsCommand}'.`, "sync.conflicts");
|
|
385
470
|
}
|
|
386
471
|
|
|
387
472
|
return usage(`Unknown sync subcommand '${subcommand}'.`);
|
package/src/sync/service.ts
CHANGED
|
@@ -6,8 +6,13 @@ import { openTrekoonDatabase, writeTransaction } from "../storage/database";
|
|
|
6
6
|
import { countBranchEventsSince, queryBranchEventsSince } from "./branch-db";
|
|
7
7
|
import { nextEventTimestamp } from "./event-writes";
|
|
8
8
|
import { persistGitContext, resolveGitContext } from "./git-context";
|
|
9
|
+
import { DomainError } from "../domain/types";
|
|
9
10
|
import {
|
|
10
11
|
type PullSummary,
|
|
12
|
+
type ResolveAllOptions,
|
|
13
|
+
type ResolveAllFilters,
|
|
14
|
+
type ResolveAllPreviewSummary,
|
|
15
|
+
type ResolveAllSummary,
|
|
11
16
|
type ResolvePreviewSummary,
|
|
12
17
|
type ResolveSummary,
|
|
13
18
|
type SyncConflictDetail,
|
|
@@ -101,6 +106,16 @@ interface ConflictRow {
|
|
|
101
106
|
readonly updated_at: number;
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
interface ResolutionWriteContext {
|
|
110
|
+
readonly branchName: string | null;
|
|
111
|
+
readonly headSha: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface ResolveAllQueryFilters {
|
|
115
|
+
readonly entityId?: string;
|
|
116
|
+
readonly fieldName?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
interface EventPayload {
|
|
105
120
|
readonly fields: Record<string, unknown>;
|
|
106
121
|
}
|
|
@@ -900,7 +915,7 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
900
915
|
}
|
|
901
916
|
|
|
902
917
|
function parseConflictValue(value: string | null): unknown {
|
|
903
|
-
if (value === null
|
|
918
|
+
if (value === null) {
|
|
904
919
|
return null;
|
|
905
920
|
}
|
|
906
921
|
|
|
@@ -914,20 +929,86 @@ function parseConflictValue(value: string | null): unknown {
|
|
|
914
929
|
function updateSingleField(db: Database, entityKind: string, entityId: string, fieldName: string, value: unknown): void {
|
|
915
930
|
const tableName = tableForEntityKind(entityKind);
|
|
916
931
|
if (!tableName) {
|
|
917
|
-
|
|
932
|
+
throw new DomainError({
|
|
933
|
+
code: "unsupported_entity_kind",
|
|
934
|
+
message: `No table mapping for entity kind: ${entityKind}`,
|
|
935
|
+
details: { entityKind },
|
|
936
|
+
});
|
|
918
937
|
}
|
|
919
938
|
|
|
920
939
|
const validFields: readonly string[] = SYNC_ALLOWED_FIELDS[tableName] ?? [];
|
|
921
940
|
if (!validFields.includes(fieldName)) {
|
|
922
|
-
|
|
941
|
+
throw new DomainError({
|
|
942
|
+
code: "disallowed_field",
|
|
943
|
+
message: `Field '${fieldName}' is not allowed for table '${tableName}'`,
|
|
944
|
+
details: { tableName, fieldName },
|
|
945
|
+
});
|
|
923
946
|
}
|
|
924
947
|
|
|
925
948
|
const now: number = Date.now();
|
|
926
|
-
|
|
927
|
-
|
|
949
|
+
const result = db
|
|
950
|
+
.query(`UPDATE ${tableName} SET ${fieldName} = ?, updated_at = ?, version = version + 1 WHERE id = ?;`)
|
|
951
|
+
.run(typeof value === "string" ? value : JSON.stringify(value), now, entityId);
|
|
952
|
+
|
|
953
|
+
if (result.changes === 0) {
|
|
954
|
+
throw new DomainError({
|
|
955
|
+
code: "row_not_found",
|
|
956
|
+
message: `No row updated: entity '${entityKind}' with id '${entityId}' not found in table '${tableName}'`,
|
|
957
|
+
details: { tableName, entityKind, entityId },
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function deleteSingleEntity(db: Database, entityKind: string, entityId: string): void {
|
|
963
|
+
const tableName = tableForEntityKind(entityKind);
|
|
964
|
+
if (!tableName) {
|
|
965
|
+
throw new DomainError({
|
|
966
|
+
code: "unsupported_entity_kind",
|
|
967
|
+
message: `No table mapping for entity kind: ${entityKind}`,
|
|
968
|
+
details: { entityKind },
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const result = db.query(`DELETE FROM ${tableName} WHERE id = ?;`).run(entityId);
|
|
973
|
+
|
|
974
|
+
if (result.changes === 0) {
|
|
975
|
+
throw new DomainError({
|
|
976
|
+
code: "row_not_found",
|
|
977
|
+
message: `No row deleted: entity '${entityKind}' with id '${entityId}' not found in table '${tableName}'`,
|
|
978
|
+
details: { tableName, entityKind, entityId },
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function normalizeResolveAllFilters(filters: ResolveAllQueryFilters): ResolveAllFilters {
|
|
984
|
+
return {
|
|
985
|
+
entity: filters.entityId ?? null,
|
|
986
|
+
field: filters.fieldName ?? null,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function resolveConflictRow(
|
|
991
|
+
db: Database,
|
|
992
|
+
conflict: ConflictRow,
|
|
993
|
+
resolution: SyncResolution,
|
|
994
|
+
git: ResolutionWriteContext,
|
|
995
|
+
): void {
|
|
996
|
+
if (resolution === "theirs") {
|
|
997
|
+
if (conflict.field_name === "__delete__") {
|
|
998
|
+
deleteSingleEntity(db, conflict.entity_kind, conflict.entity_id);
|
|
999
|
+
} else {
|
|
1000
|
+
updateSingleField(db, conflict.entity_kind, conflict.entity_id, conflict.field_name, parseConflictValue(conflict.theirs_value));
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const now: number = nextEventTimestamp(db);
|
|
1005
|
+
db.query("UPDATE sync_conflicts SET resolution = ?, updated_at = ?, version = version + 1 WHERE id = ?;").run(
|
|
1006
|
+
resolution,
|
|
928
1007
|
now,
|
|
929
|
-
|
|
1008
|
+
conflict.id,
|
|
930
1009
|
);
|
|
1010
|
+
|
|
1011
|
+
appendResolutionEvent(db, git.branchName, git.headSha, conflict, resolution, now);
|
|
931
1012
|
}
|
|
932
1013
|
|
|
933
1014
|
function appendResolutionEvent(
|
|
@@ -1086,24 +1167,7 @@ export function syncResolve(cwd: string, conflictId: string, resolution: SyncRes
|
|
|
1086
1167
|
// the check and double-resolve the same conflict.
|
|
1087
1168
|
const conflict = writeTransaction(storage.db, (): ConflictRow => {
|
|
1088
1169
|
const row = lookupPendingConflict(storage.db, conflictId);
|
|
1089
|
-
|
|
1090
|
-
if (resolution === "theirs") {
|
|
1091
|
-
updateSingleField(
|
|
1092
|
-
storage.db,
|
|
1093
|
-
row.entity_kind,
|
|
1094
|
-
row.entity_id,
|
|
1095
|
-
row.field_name,
|
|
1096
|
-
parseConflictValue(row.theirs_value),
|
|
1097
|
-
);
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
const now: number = nextEventTimestamp(storage.db);
|
|
1101
|
-
storage.db
|
|
1102
|
-
.query("UPDATE sync_conflicts SET resolution = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
|
|
1103
|
-
.run(resolution, now, row.id);
|
|
1104
|
-
|
|
1105
|
-
appendResolutionEvent(storage.db, git.branchName, git.headSha, row, resolution, now);
|
|
1106
|
-
|
|
1170
|
+
resolveConflictRow(storage.db, row, resolution, git);
|
|
1107
1171
|
return row;
|
|
1108
1172
|
});
|
|
1109
1173
|
|
|
@@ -1145,3 +1209,146 @@ export function syncResolvePreview(cwd: string, conflictId: string, resolution:
|
|
|
1145
1209
|
storage.close();
|
|
1146
1210
|
}
|
|
1147
1211
|
}
|
|
1212
|
+
|
|
1213
|
+
function queryPendingConflicts(
|
|
1214
|
+
db: Database,
|
|
1215
|
+
filters: ResolveAllQueryFilters,
|
|
1216
|
+
): readonly ConflictRow[] {
|
|
1217
|
+
const conditions: string[] = ["resolution = 'pending'"];
|
|
1218
|
+
const params: string[] = [];
|
|
1219
|
+
|
|
1220
|
+
if (filters.entityId !== undefined) {
|
|
1221
|
+
conditions.push("entity_id = ?");
|
|
1222
|
+
params.push(filters.entityId);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (filters.fieldName !== undefined) {
|
|
1226
|
+
conditions.push("field_name = ?");
|
|
1227
|
+
params.push(filters.fieldName);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const sql = `
|
|
1231
|
+
SELECT c.id, c.event_id, c.entity_kind, c.entity_id, c.field_name, c.ours_value, c.theirs_value, c.resolution, c.created_at, c.updated_at
|
|
1232
|
+
FROM sync_conflicts c
|
|
1233
|
+
LEFT JOIN events e ON e.id = c.event_id
|
|
1234
|
+
WHERE ${conditions.map((condition) => condition.replaceAll("resolution", "c.resolution").replaceAll("entity_id", "c.entity_id").replaceAll("field_name", "c.field_name")).join(" AND ")}
|
|
1235
|
+
ORDER BY COALESCE(e.created_at, c.created_at) ASC, COALESCE(e.id, c.event_id) ASC, c.created_at ASC, c.id ASC;
|
|
1236
|
+
`;
|
|
1237
|
+
|
|
1238
|
+
return db.query(sql).all(...params) as ConflictRow[];
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function queryPendingConflictsByIds(db: Database, conflictIds: readonly string[]): readonly ConflictRow[] {
|
|
1242
|
+
if (conflictIds.length === 0) {
|
|
1243
|
+
return [];
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const placeholders = conflictIds.map(() => "?").join(", ");
|
|
1247
|
+
const rows = db
|
|
1248
|
+
.query(
|
|
1249
|
+
`
|
|
1250
|
+
SELECT id, event_id, entity_kind, entity_id, field_name, ours_value, theirs_value, resolution, created_at, updated_at
|
|
1251
|
+
FROM sync_conflicts
|
|
1252
|
+
WHERE resolution = 'pending' AND id IN (${placeholders});
|
|
1253
|
+
`,
|
|
1254
|
+
)
|
|
1255
|
+
.all(...conflictIds) as ConflictRow[];
|
|
1256
|
+
|
|
1257
|
+
const rowById = new Map(rows.map((row) => [row.id, row]));
|
|
1258
|
+
|
|
1259
|
+
return conflictIds.flatMap((conflictId) => {
|
|
1260
|
+
const row = rowById.get(conflictId);
|
|
1261
|
+
return row ? [row] : [];
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
export function syncResolveAll(
|
|
1266
|
+
cwd: string,
|
|
1267
|
+
resolution: SyncResolution,
|
|
1268
|
+
filters: ResolveAllQueryFilters,
|
|
1269
|
+
options: ResolveAllOptions = {},
|
|
1270
|
+
): ResolveAllSummary {
|
|
1271
|
+
const storage = openTrekoonDatabase(cwd);
|
|
1272
|
+
const git = resolveGitContext(cwd);
|
|
1273
|
+
const normalizedFilters: ResolveAllFilters = normalizeResolveAllFilters(filters);
|
|
1274
|
+
|
|
1275
|
+
try {
|
|
1276
|
+
persistGitContext(storage.db, git);
|
|
1277
|
+
|
|
1278
|
+
const resolvedIds: string[] = writeTransaction(storage.db, (): string[] => {
|
|
1279
|
+
const expectedConflictIds = options.expectedConflictIds;
|
|
1280
|
+
const conflicts = expectedConflictIds
|
|
1281
|
+
? queryPendingConflictsByIds(storage.db, expectedConflictIds)
|
|
1282
|
+
: queryPendingConflicts(storage.db, filters);
|
|
1283
|
+
|
|
1284
|
+
if (conflicts.length === 0) {
|
|
1285
|
+
throw new DomainError({
|
|
1286
|
+
code: "no_matching_conflicts",
|
|
1287
|
+
message: "No pending conflicts match the given filters.",
|
|
1288
|
+
details: { filters: normalizedFilters },
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
if (expectedConflictIds && conflicts.length !== expectedConflictIds.length) {
|
|
1293
|
+
throw new DomainError({
|
|
1294
|
+
code: "conflict_set_changed",
|
|
1295
|
+
message: "Pending conflicts changed before batch resolution could be applied.",
|
|
1296
|
+
details: {
|
|
1297
|
+
filters: normalizedFilters,
|
|
1298
|
+
expectedConflictIds,
|
|
1299
|
+
availableConflictIds: conflicts.map((conflict) => conflict.id),
|
|
1300
|
+
},
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const ids: string[] = [];
|
|
1305
|
+
|
|
1306
|
+
for (const conflict of conflicts) {
|
|
1307
|
+
resolveConflictRow(storage.db, conflict, resolution, git);
|
|
1308
|
+
ids.push(conflict.id);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
return ids;
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
return {
|
|
1315
|
+
resolution,
|
|
1316
|
+
resolvedCount: resolvedIds.length,
|
|
1317
|
+
resolvedIds,
|
|
1318
|
+
filters: normalizedFilters,
|
|
1319
|
+
};
|
|
1320
|
+
} finally {
|
|
1321
|
+
storage.close();
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
export function syncResolveAllPreview(
|
|
1326
|
+
cwd: string,
|
|
1327
|
+
resolution: SyncResolution,
|
|
1328
|
+
filters: ResolveAllQueryFilters,
|
|
1329
|
+
): ResolveAllPreviewSummary {
|
|
1330
|
+
const storage = openTrekoonDatabase(cwd);
|
|
1331
|
+
const normalizedFilters: ResolveAllFilters = normalizeResolveAllFilters(filters);
|
|
1332
|
+
|
|
1333
|
+
try {
|
|
1334
|
+
const conflicts = queryPendingConflicts(storage.db, filters);
|
|
1335
|
+
|
|
1336
|
+
if (conflicts.length === 0) {
|
|
1337
|
+
throw new DomainError({
|
|
1338
|
+
code: "no_matching_conflicts",
|
|
1339
|
+
message: "No pending conflicts match the given filters.",
|
|
1340
|
+
details: { filters: normalizedFilters },
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
return {
|
|
1345
|
+
resolution,
|
|
1346
|
+
matchedCount: conflicts.length,
|
|
1347
|
+
matchedIds: conflicts.map((c) => c.id),
|
|
1348
|
+
filters: normalizedFilters,
|
|
1349
|
+
dryRun: true,
|
|
1350
|
+
};
|
|
1351
|
+
} finally {
|
|
1352
|
+
storage.close();
|
|
1353
|
+
}
|
|
1354
|
+
}
|
package/src/sync/types.ts
CHANGED
|
@@ -55,6 +55,30 @@ export interface ResolvePreviewSummary {
|
|
|
55
55
|
readonly dryRun: true;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export interface ResolveAllFilters {
|
|
59
|
+
readonly entity: string | null;
|
|
60
|
+
readonly field: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ResolveAllSummary {
|
|
64
|
+
readonly resolution: SyncResolution;
|
|
65
|
+
readonly resolvedCount: number;
|
|
66
|
+
readonly resolvedIds: readonly string[];
|
|
67
|
+
readonly filters: ResolveAllFilters;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ResolveAllPreviewSummary {
|
|
71
|
+
readonly resolution: SyncResolution;
|
|
72
|
+
readonly matchedCount: number;
|
|
73
|
+
readonly matchedIds: readonly string[];
|
|
74
|
+
readonly filters: ResolveAllFilters;
|
|
75
|
+
readonly dryRun: true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ResolveAllOptions {
|
|
79
|
+
readonly expectedConflictIds?: readonly string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
58
82
|
export interface SyncConflictListItem {
|
|
59
83
|
readonly id: string;
|
|
60
84
|
readonly event_id: string;
|