trekoon 0.2.0 → 0.2.1

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.
@@ -3,6 +3,14 @@ import { randomUUID } from "node:crypto";
3
3
  import { Database } from "bun:sqlite";
4
4
 
5
5
  import {
6
+ type CompactEpicExpandResult,
7
+ type CompactDependencyBatchAddResult,
8
+ type CompactDependencySpec,
9
+ type CompactEntityRef,
10
+ type CompactSubtaskBatchCreateResult,
11
+ type CompactSubtaskSpec,
12
+ type CompactTaskBatchCreateResult,
13
+ type CompactTaskSpec,
6
14
  type DependencyRecord,
7
15
  DomainError,
8
16
  type EpicTreeDetailed,
@@ -62,6 +70,47 @@ interface UnresolvedDependencyBlocker {
62
70
  readonly status: string;
63
71
  }
64
72
 
73
+ interface ValidatedTaskBatchSpec {
74
+ readonly tempKey: string;
75
+ readonly title: string;
76
+ readonly description: string;
77
+ readonly status: string;
78
+ }
79
+
80
+ interface ValidatedSubtaskBatchSpec {
81
+ readonly tempKey: string;
82
+ readonly taskId: string;
83
+ readonly title: string;
84
+ readonly description: string;
85
+ readonly status: string;
86
+ }
87
+
88
+ interface ResolvedDependencyBatchSpec {
89
+ readonly index: number;
90
+ readonly sourceId: string;
91
+ readonly sourceKind: "task" | "subtask";
92
+ readonly dependsOnId: string;
93
+ readonly dependsOnKind: "task" | "subtask";
94
+ }
95
+
96
+ interface DependencyBatchValidationIssue {
97
+ readonly index: number;
98
+ readonly type: "missing_id" | "duplicate" | "cycle";
99
+ readonly sourceId: string;
100
+ readonly dependsOnId: string;
101
+ readonly details: Record<string, unknown>;
102
+ }
103
+
104
+ interface DependencyBatchResolution {
105
+ readonly spec?: ResolvedDependencyBatchSpec;
106
+ readonly issues: readonly DependencyBatchValidationIssue[];
107
+ }
108
+
109
+ interface ResolvedCompactEntity {
110
+ readonly id: string;
111
+ readonly kind: "task" | "subtask";
112
+ }
113
+
65
114
  function assertNonEmpty(field: string, value: string | undefined | null): string {
66
115
  const normalized: string = (value ?? "").trim();
67
116
  if (!normalized) {
@@ -271,6 +320,43 @@ export class TrackerDomain {
271
320
  return this.getTaskOrThrow(id);
272
321
  }
273
322
 
323
+ createTaskBatch(input: { epicId: string; specs: readonly CompactTaskSpec[] }): CompactTaskBatchCreateResult {
324
+ const epicId: string = assertNonEmpty("epicId", input.epicId);
325
+ this.getEpicOrThrow(epicId);
326
+
327
+ const validatedSpecs: ValidatedTaskBatchSpec[] = input.specs.map((spec) => ({
328
+ tempKey: assertNonEmpty("tempKey", spec.tempKey),
329
+ title: assertNonEmpty("title", spec.title),
330
+ description: assertNonEmpty("description", spec.description),
331
+ status: normalizeStatus(spec.status),
332
+ }));
333
+
334
+ const tasks: TaskRecord[] = [];
335
+ for (const spec of validatedSpecs) {
336
+ const now: number = Date.now();
337
+ const id: string = randomUUID();
338
+
339
+ this.#db
340
+ .query(
341
+ "INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
342
+ )
343
+ .run(id, epicId, spec.title, spec.description, spec.status, now, now);
344
+
345
+ tasks.push(this.getTaskOrThrow(id));
346
+ }
347
+
348
+ return {
349
+ tasks,
350
+ result: {
351
+ mappings: tasks.map((task, index) => ({
352
+ kind: "task",
353
+ tempKey: validatedSpecs[index]?.tempKey ?? "",
354
+ id: task.id,
355
+ })),
356
+ },
357
+ };
358
+ }
359
+
274
360
  listTasks(epicId?: string): readonly TaskRecord[] {
275
361
  if (epicId) {
276
362
  this.getEpicOrThrow(epicId);
@@ -353,6 +439,89 @@ export class TrackerDomain {
353
439
  return this.getSubtaskOrThrow(id);
354
440
  }
355
441
 
442
+ createSubtaskBatch(input: { taskId: string; specs: readonly CompactSubtaskSpec[] }): CompactSubtaskBatchCreateResult {
443
+ const defaultTaskId: string = assertNonEmpty("taskId", input.taskId);
444
+ this.getTaskOrThrow(defaultTaskId);
445
+
446
+ const validatedSpecs: ValidatedSubtaskBatchSpec[] = input.specs.map((spec) => {
447
+ const taskId = spec.parent.kind === "id" ? assertNonEmpty("taskId", spec.parent.id) : defaultTaskId;
448
+ this.getTaskOrThrow(taskId);
449
+
450
+ return {
451
+ tempKey: assertNonEmpty("tempKey", spec.tempKey),
452
+ taskId,
453
+ title: assertNonEmpty("title", spec.title),
454
+ description: spec.description === undefined ? "" : assertNonEmpty("description", spec.description),
455
+ status: normalizeStatus(spec.status),
456
+ };
457
+ });
458
+
459
+ const subtasks: SubtaskRecord[] = [];
460
+ for (const spec of validatedSpecs) {
461
+ const now: number = Date.now();
462
+ const id: string = randomUUID();
463
+
464
+ this.#db
465
+ .query(
466
+ "INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
467
+ )
468
+ .run(id, spec.taskId, spec.title, spec.description, spec.status, now, now);
469
+
470
+ subtasks.push(this.getSubtaskOrThrow(id));
471
+ }
472
+
473
+ return {
474
+ subtasks,
475
+ result: {
476
+ mappings: subtasks.map((subtask, index) => ({
477
+ kind: "subtask",
478
+ tempKey: validatedSpecs[index]?.tempKey ?? "",
479
+ id: subtask.id,
480
+ })),
481
+ },
482
+ };
483
+ }
484
+
485
+ expandEpic(input: {
486
+ epicId: string;
487
+ taskSpecs: readonly CompactTaskSpec[];
488
+ subtaskSpecs: readonly CompactSubtaskSpec[];
489
+ dependencySpecs: readonly CompactDependencySpec[];
490
+ }): CompactEpicExpandResult {
491
+ const createdTasks = this.createTaskBatch({
492
+ epicId: input.epicId,
493
+ specs: input.taskSpecs,
494
+ });
495
+
496
+ const resolvedSubtaskSpecs = this.#resolveEpicExpandSubtaskSpecs(input.subtaskSpecs, createdTasks.result.mappings);
497
+ const createdSubtasks = resolvedSubtaskSpecs.length === 0
498
+ ? { subtasks: [], result: { mappings: [] } }
499
+ : this.createSubtaskBatch({
500
+ taskId: resolvedSubtaskSpecs[0]?.parent.kind === "id" ? resolvedSubtaskSpecs[0].parent.id : "",
501
+ specs: resolvedSubtaskSpecs,
502
+ });
503
+
504
+ const mappings = [...createdTasks.result.mappings, ...createdSubtasks.result.mappings];
505
+ const resolvedDependencySpecs = this.#resolveEpicExpandDependencySpecs(input.dependencySpecs, mappings);
506
+ const createdDependencies = resolvedDependencySpecs.length === 0
507
+ ? { dependencies: [], result: { mappings: [] } }
508
+ : this.addDependencyBatch({ specs: resolvedDependencySpecs });
509
+
510
+ return {
511
+ tasks: createdTasks.tasks,
512
+ subtasks: createdSubtasks.subtasks,
513
+ dependencies: createdDependencies.dependencies,
514
+ result: {
515
+ mappings,
516
+ counts: {
517
+ tasks: createdTasks.tasks.length,
518
+ subtasks: createdSubtasks.subtasks.length,
519
+ dependencies: createdDependencies.dependencies.length,
520
+ },
521
+ },
522
+ };
523
+ }
524
+
356
525
  listSubtasks(taskId?: string): readonly SubtaskRecord[] {
357
526
  if (taskId) {
358
527
  this.getTaskOrThrow(taskId);
@@ -633,6 +802,54 @@ export class TrackerDomain {
633
802
  return this.getDependencyOrThrow(id);
634
803
  }
635
804
 
805
+ addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
806
+ const resolutions = input.specs.map((spec, index) => this.#resolveDependencyBatchSpec(index, spec));
807
+ const resolvedSpecs = resolutions.flatMap((resolution) => (resolution.spec === undefined ? [] : [resolution.spec]));
808
+ const issues = resolutions.flatMap((resolution) => resolution.issues).concat(this.#collectDependencyBatchIssues(resolvedSpecs));
809
+ if (issues.length > 0) {
810
+ const orderedIssues = issues.sort((left, right) => left.index - right.index || left.type.localeCompare(right.type));
811
+ const firstIssue = orderedIssues[0] ?? null;
812
+ throw new DomainError({
813
+ code: "invalid_dependency",
814
+ message:
815
+ firstIssue?.type === "missing_id"
816
+ ? "dependency batch contains missing ids"
817
+ : firstIssue?.type === "duplicate"
818
+ ? "dependency batch contains duplicate edges"
819
+ : "dependency batch contains cycles",
820
+ details: {
821
+ issues: orderedIssues,
822
+ firstIssue,
823
+ },
824
+ });
825
+ }
826
+
827
+ const dependencies: DependencyRecord[] = [];
828
+ for (const spec of resolvedSpecs) {
829
+ const existing = this.#getDependencyByEdge(spec.sourceId, spec.dependsOnId);
830
+ if (existing) {
831
+ dependencies.push(existing);
832
+ continue;
833
+ }
834
+
835
+ const id: string = randomUUID();
836
+ const now: number = Date.now();
837
+
838
+ this.#db
839
+ .query(
840
+ "INSERT INTO dependencies (id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
841
+ )
842
+ .run(id, spec.sourceId, spec.sourceKind, spec.dependsOnId, spec.dependsOnKind, now, now);
843
+
844
+ dependencies.push(this.getDependencyOrThrow(id));
845
+ }
846
+
847
+ return {
848
+ dependencies,
849
+ result: { mappings: [] },
850
+ };
851
+ }
852
+
636
853
  removeDependency(sourceId: string, dependsOnId: string): number {
637
854
  const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
638
855
  const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
@@ -707,6 +924,292 @@ export class TrackerDomain {
707
924
  return mapDependency(row);
708
925
  }
709
926
 
927
+ #getDependencyByEdge(sourceId: string, dependsOnId: string): DependencyRecord | null {
928
+ const row = this.#db
929
+ .query(
930
+ "SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE source_id = ? AND depends_on_id = ?;",
931
+ )
932
+ .get(sourceId, dependsOnId) as DependencyRow | null;
933
+
934
+ return row ? mapDependency(row) : null;
935
+ }
936
+
937
+ #resolveDependencyBatchSpec(index: number, spec: CompactDependencySpec): DependencyBatchResolution {
938
+ const sourceResolution = this.#resolveDependencyBatchId(spec.source, "source", index);
939
+ const dependsOnResolution = this.#resolveDependencyBatchId(spec.dependsOn, "dependsOn", index);
940
+ const issues = [...sourceResolution.issues, ...dependsOnResolution.issues];
941
+ const sourceId = sourceResolution.id;
942
+ const dependsOnId = dependsOnResolution.id;
943
+
944
+ if (sourceId === undefined || dependsOnId === undefined) {
945
+ return {
946
+ issues,
947
+ };
948
+ }
949
+
950
+ if (sourceId === dependsOnId) {
951
+ return {
952
+ issues: [
953
+ ...issues,
954
+ {
955
+ index,
956
+ type: "cycle",
957
+ sourceId,
958
+ dependsOnId,
959
+ details: { sourceId, dependsOnId, reason: "self_reference" },
960
+ },
961
+ ],
962
+ };
963
+ }
964
+
965
+ return {
966
+ spec: {
967
+ index,
968
+ sourceId,
969
+ sourceKind: this.resolveNodeKind(sourceId),
970
+ dependsOnId,
971
+ dependsOnKind: this.resolveNodeKind(dependsOnId),
972
+ },
973
+ issues,
974
+ };
975
+ }
976
+
977
+ #resolveDependencyBatchId(
978
+ reference: CompactEntityRef,
979
+ field: "source" | "dependsOn",
980
+ index: number,
981
+ ): { readonly id?: string; readonly issues: readonly DependencyBatchValidationIssue[] } {
982
+ if (reference.kind === "temp_key") {
983
+ return {
984
+ issues: [
985
+ {
986
+ index,
987
+ type: "missing_id",
988
+ sourceId: field === "source" ? `@${reference.tempKey}` : "",
989
+ dependsOnId: field === "dependsOn" ? `@${reference.tempKey}` : "",
990
+ details: {
991
+ field,
992
+ tempKey: reference.tempKey,
993
+ message: `Unresolved temp key @${reference.tempKey}`,
994
+ },
995
+ },
996
+ ],
997
+ };
998
+ }
999
+
1000
+ const id = assertNonEmpty(field === "source" ? "sourceId" : "dependsOnId", reference.id);
1001
+ const task = this.getTask(id);
1002
+ const subtask = this.getSubtask(id);
1003
+ if (!task && !subtask) {
1004
+ return {
1005
+ issues: [
1006
+ {
1007
+ index,
1008
+ type: "missing_id",
1009
+ sourceId: field === "source" ? id : "",
1010
+ dependsOnId: field === "dependsOn" ? id : "",
1011
+ details: {
1012
+ field,
1013
+ id,
1014
+ message: `Node not found: ${id}`,
1015
+ },
1016
+ },
1017
+ ],
1018
+ };
1019
+ }
1020
+
1021
+ return { id, issues: [] };
1022
+ }
1023
+
1024
+ #resolveEpicExpandSubtaskSpecs(
1025
+ specs: readonly CompactSubtaskSpec[],
1026
+ mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
1027
+ ): CompactSubtaskSpec[] {
1028
+ return specs.map((spec, index) => {
1029
+ const parent = this.#resolveEpicExpandEntityRef(spec.parent, mappings, "subtask", index, "parent");
1030
+ if (parent.kind !== "task") {
1031
+ throw new DomainError({
1032
+ code: "invalid_input",
1033
+ message: `Subtask parent must resolve to a task in --subtask spec ${index + 1}`,
1034
+ details: {
1035
+ index,
1036
+ field: "parent",
1037
+ kind: parent.kind,
1038
+ id: parent.id,
1039
+ },
1040
+ });
1041
+ }
1042
+
1043
+ return {
1044
+ ...spec,
1045
+ parent: {
1046
+ kind: "id",
1047
+ id: parent.id,
1048
+ },
1049
+ };
1050
+ });
1051
+ }
1052
+
1053
+ #resolveEpicExpandDependencySpecs(
1054
+ specs: readonly CompactDependencySpec[],
1055
+ mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
1056
+ ): CompactDependencySpec[] {
1057
+ return specs.map((spec, index) => ({
1058
+ source: {
1059
+ kind: "id",
1060
+ id: this.#resolveEpicExpandEntityRef(spec.source, mappings, "dep", index, "source").id,
1061
+ },
1062
+ dependsOn: {
1063
+ kind: "id",
1064
+ id: this.#resolveEpicExpandEntityRef(spec.dependsOn, mappings, "dep", index, "dependsOn").id,
1065
+ },
1066
+ }));
1067
+ }
1068
+
1069
+ #resolveEpicExpandEntityRef(
1070
+ reference: CompactEntityRef,
1071
+ mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
1072
+ option: "subtask" | "dep",
1073
+ index: number,
1074
+ field: "parent" | "source" | "dependsOn",
1075
+ ): ResolvedCompactEntity {
1076
+ if (reference.kind === "temp_key") {
1077
+ const mapping = mappings.find((candidate) => candidate.tempKey === reference.tempKey);
1078
+ if (mapping === undefined) {
1079
+ throw new DomainError({
1080
+ code: "invalid_input",
1081
+ message: `Unknown temp key @${reference.tempKey} in --${option} spec ${index + 1}`,
1082
+ details: {
1083
+ index,
1084
+ field,
1085
+ tempKey: reference.tempKey,
1086
+ option,
1087
+ },
1088
+ });
1089
+ }
1090
+
1091
+ return {
1092
+ id: mapping.id,
1093
+ kind: mapping.kind,
1094
+ };
1095
+ }
1096
+
1097
+ const id = assertNonEmpty(field === "parent" ? "taskId" : `${field}Id`, reference.id);
1098
+ return {
1099
+ id,
1100
+ kind: this.resolveNodeKind(id),
1101
+ };
1102
+ }
1103
+
1104
+ #collectDependencyBatchIssues(specs: readonly ResolvedDependencyBatchSpec[]): DependencyBatchValidationIssue[] {
1105
+ const issues: DependencyBatchValidationIssue[] = [];
1106
+ const seenEdges = new Map<string, number>();
1107
+ const adjacency = this.#buildDependencyAdjacency();
1108
+
1109
+ for (const spec of specs) {
1110
+ const edgeKey = `${spec.sourceId}->${spec.dependsOnId}`;
1111
+ const existingIndex = seenEdges.get(edgeKey);
1112
+ if (existingIndex !== undefined) {
1113
+ issues.push({
1114
+ index: spec.index,
1115
+ type: "duplicate",
1116
+ sourceId: spec.sourceId,
1117
+ dependsOnId: spec.dependsOnId,
1118
+ details: {
1119
+ sourceId: spec.sourceId,
1120
+ dependsOnId: spec.dependsOnId,
1121
+ firstIndex: existingIndex,
1122
+ duplicateIndex: spec.index,
1123
+ duplicateKind: "batch",
1124
+ },
1125
+ });
1126
+ continue;
1127
+ }
1128
+
1129
+ if (this.#getDependencyByEdge(spec.sourceId, spec.dependsOnId) !== null) {
1130
+ issues.push({
1131
+ index: spec.index,
1132
+ type: "duplicate",
1133
+ sourceId: spec.sourceId,
1134
+ dependsOnId: spec.dependsOnId,
1135
+ details: {
1136
+ sourceId: spec.sourceId,
1137
+ dependsOnId: spec.dependsOnId,
1138
+ duplicateKind: "existing",
1139
+ },
1140
+ });
1141
+ continue;
1142
+ }
1143
+
1144
+ if (this.#wouldCreateCycleInAdjacency(adjacency, spec.sourceId, spec.dependsOnId)) {
1145
+ issues.push({
1146
+ index: spec.index,
1147
+ type: "cycle",
1148
+ sourceId: spec.sourceId,
1149
+ dependsOnId: spec.dependsOnId,
1150
+ details: {
1151
+ sourceId: spec.sourceId,
1152
+ dependsOnId: spec.dependsOnId,
1153
+ },
1154
+ });
1155
+ continue;
1156
+ }
1157
+
1158
+ const nextNeighbors = adjacency.get(spec.sourceId) ?? new Set<string>();
1159
+ nextNeighbors.add(spec.dependsOnId);
1160
+ adjacency.set(spec.sourceId, nextNeighbors);
1161
+ seenEdges.set(edgeKey, spec.index);
1162
+ }
1163
+
1164
+ return issues.sort((left, right) => left.index - right.index || left.type.localeCompare(right.type));
1165
+ }
1166
+
1167
+ #buildDependencyAdjacency(): Map<string, Set<string>> {
1168
+ const rows = this.#db.query("SELECT source_id, depends_on_id FROM dependencies ORDER BY source_id ASC, depends_on_id ASC;").all() as Array<{
1169
+ source_id: string;
1170
+ depends_on_id: string;
1171
+ }>;
1172
+ const adjacency = new Map<string, Set<string>>();
1173
+
1174
+ for (const row of rows) {
1175
+ const neighbors = adjacency.get(row.source_id) ?? new Set<string>();
1176
+ neighbors.add(row.depends_on_id);
1177
+ adjacency.set(row.source_id, neighbors);
1178
+ }
1179
+
1180
+ return adjacency;
1181
+ }
1182
+
1183
+ #wouldCreateCycleInAdjacency(adjacency: ReadonlyMap<string, ReadonlySet<string>>, sourceId: string, dependsOnId: string): boolean {
1184
+ const visited = new Set<string>();
1185
+ const queue: string[] = [dependsOnId];
1186
+
1187
+ while (queue.length > 0) {
1188
+ const current = queue.shift();
1189
+ if (current === undefined || visited.has(current)) {
1190
+ continue;
1191
+ }
1192
+
1193
+ if (current === sourceId) {
1194
+ return true;
1195
+ }
1196
+
1197
+ visited.add(current);
1198
+ const neighbors = adjacency.get(current);
1199
+ if (neighbors === undefined) {
1200
+ continue;
1201
+ }
1202
+
1203
+ for (const neighbor of neighbors) {
1204
+ if (!visited.has(neighbor)) {
1205
+ queue.push(neighbor);
1206
+ }
1207
+ }
1208
+ }
1209
+
1210
+ return false;
1211
+ }
1212
+
710
1213
  private collectSearchMatches(
711
1214
  nodes: readonly SearchNode[],
712
1215
  searchText: string,
@@ -1,5 +1,85 @@
1
1
  export type NodeKind = "epic" | "task" | "subtask";
2
2
 
3
+ export const COMPACT_TEMP_KEY_PREFIX = "@";
4
+
5
+ export type CompactTempKey = string;
6
+
7
+ export interface CompactTempKeyRef {
8
+ readonly kind: "temp_key";
9
+ readonly tempKey: CompactTempKey;
10
+ }
11
+
12
+ export interface CompactEntityIdRef {
13
+ readonly kind: "id";
14
+ readonly id: string;
15
+ }
16
+
17
+ export type CompactEntityRef = CompactTempKeyRef | CompactEntityIdRef;
18
+
19
+ export interface CompactTaskSpec {
20
+ readonly tempKey: CompactTempKey;
21
+ readonly title: string;
22
+ readonly description: string;
23
+ readonly status?: string;
24
+ }
25
+
26
+ export interface CompactSubtaskSpec {
27
+ readonly parent: CompactEntityRef;
28
+ readonly tempKey: CompactTempKey;
29
+ readonly title: string;
30
+ readonly description: string;
31
+ readonly status?: string;
32
+ }
33
+
34
+ export interface CompactDependencySpec {
35
+ readonly source: CompactEntityRef;
36
+ readonly dependsOn: CompactEntityRef;
37
+ }
38
+
39
+ export interface CompactTempKeyMapping<TKind extends Extract<NodeKind, "task" | "subtask"> = Extract<NodeKind, "task" | "subtask">> {
40
+ readonly kind: TKind;
41
+ readonly tempKey: CompactTempKey;
42
+ readonly id: string;
43
+ }
44
+
45
+ export interface CompactBatchResultContract {
46
+ readonly mappings: ReadonlyArray<CompactTempKeyMapping>;
47
+ }
48
+
49
+ export interface CompactTaskBatchCreateResult {
50
+ readonly tasks: ReadonlyArray<TaskRecord>;
51
+ readonly result: CompactBatchResultContract;
52
+ }
53
+
54
+ export interface CompactSubtaskBatchCreateResult {
55
+ readonly subtasks: ReadonlyArray<SubtaskRecord>;
56
+ readonly result: CompactBatchResultContract;
57
+ }
58
+
59
+ export interface CompactDependencyBatchAddResult {
60
+ readonly dependencies: ReadonlyArray<DependencyRecord>;
61
+ readonly result: CompactBatchResultContract;
62
+ }
63
+
64
+ export interface CompactBatchCounts {
65
+ readonly tasks: number;
66
+ readonly subtasks: number;
67
+ readonly dependencies: number;
68
+ }
69
+
70
+ export interface CompactEpicExpandResult {
71
+ readonly tasks: ReadonlyArray<TaskRecord>;
72
+ readonly subtasks: ReadonlyArray<SubtaskRecord>;
73
+ readonly dependencies: ReadonlyArray<DependencyRecord>;
74
+ readonly result: CompactBatchResultContract & {
75
+ readonly counts: CompactBatchCounts;
76
+ };
77
+ }
78
+
79
+ export interface CompactEpicCreateResult extends CompactEpicExpandResult {
80
+ readonly epic: EpicRecord;
81
+ }
82
+
3
83
  export interface EpicRecord {
4
84
  readonly id: string;
5
85
  readonly title: string;
@@ -401,6 +401,18 @@ function rowExists(db: Database, tableName: string, id: string): boolean {
401
401
  return row !== null;
402
402
  }
403
403
 
404
+ function dependencyNodeExists(db: Database, nodeKind: string, nodeId: string): boolean {
405
+ if (nodeKind === "task") {
406
+ return rowExists(db, "tasks", nodeId);
407
+ }
408
+
409
+ if (nodeKind === "subtask") {
410
+ return rowExists(db, "subtasks", nodeId);
411
+ }
412
+
413
+ return false;
414
+ }
415
+
404
416
  function validateRequiredStringField(fields: Record<string, unknown>, fieldName: string): string | null {
405
417
  const value: unknown = fields[fieldName];
406
418
  if (typeof value !== "string" || value.length === 0) {
@@ -503,6 +515,10 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
503
515
  return false;
504
516
  }
505
517
 
518
+ if (!dependencyNodeExists(db, sourceKind, sourceId) || !dependencyNodeExists(db, dependsOnKind, dependsOnId)) {
519
+ return false;
520
+ }
521
+
506
522
  db.query(
507
523
  `
508
524
  INSERT INTO dependencies (
@@ -625,6 +641,28 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
625
641
  return false;
626
642
  }
627
643
 
644
+ function applyReplayedCreateWithConflicts(
645
+ db: Database,
646
+ event: StoredEvent,
647
+ fields: Record<string, unknown>,
648
+ withheldConflictCount: number,
649
+ ): boolean {
650
+ if (withheldConflictCount === 0 || !event.operation.endsWith(".created")) {
651
+ return false;
652
+ }
653
+
654
+ const tableName = tableForEntityKind(event.entity_kind);
655
+ if (!tableName || !rowExists(db, tableName, event.entity_id)) {
656
+ return false;
657
+ }
658
+
659
+ if (Object.keys(fields).length === 0) {
660
+ return true;
661
+ }
662
+
663
+ return applyUpdatePatch(db, event, fields);
664
+ }
665
+
628
666
  function storeEvent(db: Database, event: StoredEvent): void {
629
667
  db.query(
630
668
  `
@@ -727,11 +765,13 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
727
765
 
728
766
  const payload: EventPayload = { fields: payloadValidation.fields };
729
767
  const fieldsToApply: Record<string, unknown> = {};
768
+ let withheldConflictCount = 0;
730
769
 
731
770
  for (const [fieldName, value] of Object.entries(payload.fields)) {
732
771
  const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
733
772
 
734
773
  if (conflict) {
774
+ withheldConflictCount += 1;
735
775
  conflictEvents += 1;
736
776
  createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
737
777
  createdConflicts += 1;
@@ -743,6 +783,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
743
783
 
744
784
  if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
745
785
  appliedEvents += 1;
786
+ } else if (applyReplayedCreateWithConflicts(storage.db, incoming, fieldsToApply, withheldConflictCount)) {
787
+ appliedEvents += 1;
746
788
  } else if (incoming.operation !== "resolve_conflict") {
747
789
  applyRejectedEvents += 1;
748
790
  quarantinedEvents += 1;