trekoon 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
 
3
3
  import { type Database } from "bun:sqlite";
4
4
 
5
+ import { ENTITY_OPERATIONS } from "../domain/mutation-operations";
5
6
  import { openTrekoonDatabase, writeTransaction } from "../storage/database";
6
7
  import { countBranchEventsSince, queryBranchEventsSinceBatch } from "./branch-db";
7
8
  import { nextEventTimestamp } from "./event-writes";
@@ -29,6 +30,8 @@ const SYNC_ALLOWED_FIELDS: Readonly<Record<string, readonly string[]>> = {
29
30
  dependencies: ["source_id", "source_kind", "depends_on_id", "depends_on_kind"],
30
31
  };
31
32
 
33
+ const SYNC_EVENT_METADATA_FIELDS = new Set(["dependency_id", "source_event_id"]);
34
+
32
35
  function isSyncNullableStringField(tableName: string, fieldName: string): boolean {
33
36
  return (tableName === "tasks" || tableName === "subtasks") && fieldName === "owner";
34
37
  }
@@ -156,6 +159,13 @@ interface LocalEntityEventRow {
156
159
  readonly id: string;
157
160
  }
158
161
 
162
+ interface DependencyEventIdentity {
163
+ readonly sourceId: string;
164
+ readonly sourceKind: string;
165
+ readonly dependsOnId: string;
166
+ readonly dependsOnKind: string;
167
+ }
168
+
159
169
  const SYNC_PULL_BATCH_SIZE = 250;
160
170
  const CONFLICT_HISTORY_SCAN_BATCH_SIZE = 250;
161
171
  const RESOLVE_ALL_CHUNK_SIZE = 200;
@@ -387,6 +397,37 @@ function currentEntityFieldValue(db: Database, entityKind: string, entityId: str
387
397
  return row?.value;
388
398
  }
389
399
 
400
+ function dependencyEventIdentityFromFields(fields: Record<string, unknown>): DependencyEventIdentity | null {
401
+ const sourceId = validateRequiredStringField(fields, "source_id");
402
+ const sourceKind = validateRequiredStringField(fields, "source_kind");
403
+ const dependsOnId = validateRequiredStringField(fields, "depends_on_id");
404
+ const dependsOnKind = validateRequiredStringField(fields, "depends_on_kind");
405
+
406
+ if (!sourceId || !sourceKind || !dependsOnId || !dependsOnKind) {
407
+ return null;
408
+ }
409
+
410
+ return {
411
+ sourceId,
412
+ sourceKind,
413
+ dependsOnId,
414
+ dependsOnKind,
415
+ };
416
+ }
417
+
418
+ function dependencyEventIdentity(event: StoredEvent): DependencyEventIdentity | null {
419
+ if (event.entity_kind !== "dependency") {
420
+ return null;
421
+ }
422
+
423
+ const payloadValidation = parsePayload(event.payload);
424
+ if (!payloadValidation.ok) {
425
+ return null;
426
+ }
427
+
428
+ return dependencyEventIdentityFromFields(payloadValidation.fields);
429
+ }
430
+
390
431
  function entityFieldConflict(
391
432
  localDb: Database,
392
433
  sourceBranch: string,
@@ -429,6 +470,8 @@ function entityFieldConflict(
429
470
  CONFLICT_HISTORY_SCAN_BATCH_SIZE,
430
471
  ) as LocalEntityEventRow[];
431
472
 
473
+ const incomingDependencyIdentity = dependencyEventIdentity(event);
474
+
432
475
  if (rows.length === 0) {
433
476
  return null;
434
477
  }
@@ -439,6 +482,19 @@ function entityFieldConflict(
439
482
  continue;
440
483
  }
441
484
 
485
+ if (incomingDependencyIdentity !== null) {
486
+ const localDependencyIdentity = dependencyEventIdentityFromFields(payloadValidation.fields);
487
+ if (
488
+ localDependencyIdentity === null ||
489
+ localDependencyIdentity.sourceId !== incomingDependencyIdentity.sourceId ||
490
+ localDependencyIdentity.sourceKind !== incomingDependencyIdentity.sourceKind ||
491
+ localDependencyIdentity.dependsOnId !== incomingDependencyIdentity.dependsOnId ||
492
+ localDependencyIdentity.dependsOnKind !== incomingDependencyIdentity.dependsOnKind
493
+ ) {
494
+ continue;
495
+ }
496
+ }
497
+
442
498
  const payload: EventPayload = { fields: payloadValidation.fields };
443
499
  const localValue: unknown = readFieldValue(payload, fieldName);
444
500
 
@@ -724,6 +780,124 @@ function hasLocalDependencyEditsTouchingNodes(db: Database, nodeIds: readonly st
724
780
  return false;
725
781
  }
726
782
 
783
+ function hasLocalDependencyEditsForIdentity(
784
+ db: Database,
785
+ sourceBranch: string,
786
+ identity: DependencyEventIdentity,
787
+ ): boolean {
788
+ const row = db
789
+ .query(
790
+ `
791
+ SELECT 1
792
+ FROM events
793
+ WHERE entity_kind = 'dependency'
794
+ AND (git_branch IS NULL OR git_branch != ?)
795
+ AND json_extract(payload, '$.fields.source_id') = ?
796
+ AND json_extract(payload, '$.fields.source_kind') = ?
797
+ AND json_extract(payload, '$.fields.depends_on_id') = ?
798
+ AND json_extract(payload, '$.fields.depends_on_kind') = ?
799
+ LIMIT 1;
800
+ `,
801
+ )
802
+ .get(sourceBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
803
+
804
+ return row !== null;
805
+ }
806
+
807
+ function dependencyRowExistsForIdentity(db: Database, identity: DependencyEventIdentity): boolean {
808
+ const row = db
809
+ .query(
810
+ `
811
+ SELECT 1
812
+ FROM dependencies
813
+ WHERE source_id = ?
814
+ AND source_kind = ?
815
+ AND depends_on_id = ?
816
+ AND depends_on_kind = ?
817
+ LIMIT 1;
818
+ `,
819
+ )
820
+ .get(identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind);
821
+
822
+ return row !== null;
823
+ }
824
+
825
+ function latestLocalDependencyOperationForIdentity(
826
+ db: Database,
827
+ sourceBranch: string,
828
+ identity: DependencyEventIdentity,
829
+ ): string | null {
830
+ const row = db
831
+ .query(
832
+ `
833
+ SELECT operation
834
+ FROM events
835
+ WHERE entity_kind = 'dependency'
836
+ AND (git_branch IS NULL OR git_branch != ?)
837
+ AND json_extract(payload, '$.fields.source_id') = ?
838
+ AND json_extract(payload, '$.fields.source_kind') = ?
839
+ AND json_extract(payload, '$.fields.depends_on_id') = ?
840
+ AND json_extract(payload, '$.fields.depends_on_kind') = ?
841
+ ORDER BY created_at DESC, id DESC
842
+ LIMIT 1;
843
+ `,
844
+ )
845
+ .get(sourceBranch, identity.sourceId, identity.sourceKind, identity.dependsOnId, identity.dependsOnKind) as
846
+ | { operation: string }
847
+ | null;
848
+
849
+ return row?.operation ?? null;
850
+ }
851
+
852
+ function hasLocalDependencyRemovalForIdentity(
853
+ db: Database,
854
+ sourceBranch: string,
855
+ identity: DependencyEventIdentity,
856
+ ): boolean {
857
+ const row = db
858
+ .query(
859
+ `
860
+ SELECT 1
861
+ FROM events
862
+ WHERE entity_kind = 'dependency'
863
+ AND operation = 'dependency.removed'
864
+ AND (git_branch IS NULL OR git_branch != ?)
865
+ AND json_extract(payload, '$.fields.source_id') = ?
866
+ AND json_extract(payload, '$.fields.depends_on_id') = ?
867
+ AND (
868
+ json_extract(payload, '$.fields.source_kind') IS NULL
869
+ OR json_extract(payload, '$.fields.source_kind') = ?
870
+ )
871
+ AND (
872
+ json_extract(payload, '$.fields.depends_on_kind') IS NULL
873
+ OR json_extract(payload, '$.fields.depends_on_kind') = ?
874
+ )
875
+ LIMIT 1;
876
+ `,
877
+ )
878
+ .get(sourceBranch, identity.sourceId, identity.dependsOnId, identity.sourceKind, identity.dependsOnKind);
879
+
880
+ return row !== null;
881
+ }
882
+
883
+ function hasLocalDependencyDeleteConflict(db: Database, event: StoredEvent, sourceBranch: string): boolean {
884
+ const identity = dependencyEventIdentity(event);
885
+ if (identity === null) {
886
+ return false;
887
+ }
888
+
889
+ if (!dependencyRowExistsForIdentity(db, identity)) {
890
+ return false;
891
+ }
892
+
893
+ const latestOperation = latestLocalDependencyOperationForIdentity(db, sourceBranch, identity);
894
+ if (latestOperation === ENTITY_OPERATIONS.dependency.removed) {
895
+ return false;
896
+ }
897
+
898
+ return hasLocalDependencyEditsForIdentity(db, sourceBranch, identity);
899
+ }
900
+
727
901
  function hasLocalDeleteCascadeEdits(db: Database, event: StoredEvent, sourceBranch: string): boolean {
728
902
  if (hasLocalEntityEdits(db, event.entity_kind, event.entity_id, sourceBranch)) {
729
903
  return true;
@@ -869,6 +1043,7 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
869
1043
  const sourceKind = validateRequiredStringField(fields, "source_kind");
870
1044
  const dependsOnId = validateRequiredStringField(fields, "depends_on_id");
871
1045
  const dependsOnKind = validateRequiredStringField(fields, "depends_on_kind");
1046
+ const dependencyId = validateRequiredStringField(fields, "dependency_id") ?? event.entity_id;
872
1047
 
873
1048
  if (!sourceId || !sourceKind || !dependsOnId || !dependsOnKind) {
874
1049
  return false;
@@ -891,13 +1066,12 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
891
1066
  version
892
1067
  ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
893
1068
  ON CONFLICT(source_id, depends_on_id) DO UPDATE SET
894
- id = excluded.id,
895
1069
  source_kind = excluded.source_kind,
896
1070
  depends_on_kind = excluded.depends_on_kind,
897
1071
  updated_at = excluded.updated_at,
898
1072
  version = dependencies.version + 1;
899
1073
  `,
900
- ).run(event.entity_id, sourceId, sourceKind, dependsOnId, dependsOnKind, now, now);
1074
+ ).run(dependencyId, sourceId, sourceKind, dependsOnId, dependsOnKind, now, now);
901
1075
 
902
1076
  return true;
903
1077
  }
@@ -1242,8 +1416,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
1242
1416
  }
1243
1417
 
1244
1418
  const isDeleteWithLocalEdits =
1245
- incoming.operation.endsWith(".deleted") &&
1246
- hasLocalDeleteCascadeEdits(storage.db, incoming, sourceBranch);
1419
+ (incoming.operation.endsWith(".deleted") && hasLocalDeleteCascadeEdits(storage.db, incoming, sourceBranch)) ||
1420
+ (incoming.operation === "dependency.removed" && hasLocalDependencyDeleteConflict(storage.db, incoming, sourceBranch));
1247
1421
  if (isDeleteWithLocalEdits) {
1248
1422
  createConflict(storage.db, incoming, "__delete__", null, "Entity deleted on source branch");
1249
1423
  createdConflicts += 1;
@@ -1258,6 +1432,11 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
1258
1432
  let withheldConflictCount = 0;
1259
1433
 
1260
1434
  for (const [fieldName, value] of Object.entries(payload.fields)) {
1435
+ if (SYNC_EVENT_METADATA_FIELDS.has(fieldName)) {
1436
+ fieldsToApply[fieldName] = value;
1437
+ continue;
1438
+ }
1439
+
1261
1440
  const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
1262
1441
 
1263
1442
  if (conflict) {