trekoon 0.3.4 → 0.3.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.
@@ -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` overwrites with the source-branch value.
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 (defaults
254
- to rejection). Toon mode skips the prompt.
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)
@@ -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. In human mode, `--use theirs` prompts for confirmation with a
174
- 30-second timeout.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "AI-first local issue tracker CLI.",
5
5
  "keywords": [
6
6
  "ai",
@@ -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 Overwrite the DB field with the source-branch value.",
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 = [
@@ -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. In human mode, --use theirs",
68
- " prompts for confirmation (30s timeout, defaults to reject).",
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: [
@@ -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("sync resolve requires <conflict-id> --use ours|theirs.", "sync.resolve");
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 (!conflictId || !rawResolution) {
249
- return usage("sync resolve requires <conflict-id> --use ours|theirs.", "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, resolution);
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 !== "toon") {
279
- const preview = syncResolvePreview(context.cwd, conflictId, resolution);
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, resolution);
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
- return usage("sync conflicts requires list|show.", "sync.conflicts");
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
- return usage("sync conflicts list --mode only accepts pending|all.", "sync.conflicts.list");
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
- return usage("sync conflicts list --mode only accepts pending|all.", "sync.conflicts.list");
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
- if (!conflictId) {
363
- return usage("sync conflicts show requires <conflict-id>.", "sync.conflicts.show");
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
- return usage(`Unknown sync conflicts subcommand '${conflictsCommand}'.`, "sync.conflicts");
469
+ return usage(`Unknown sync conflicts subcommand '${conflictsCommand}'.`, "sync.conflicts");
385
470
  }
386
471
 
387
472
  return usage(`Unknown sync subcommand '${subcommand}'.`);
@@ -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 || value === undefined) {
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
- return;
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
- return;
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
- db.query(`UPDATE ${tableName} SET ${fieldName} = ?, updated_at = ?, version = version + 1 WHERE id = ?;`).run(
927
- typeof value === "string" ? value : JSON.stringify(value),
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
- entityId,
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;