dsh-ferry 0.1.1 → 0.1.2

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/lib/ferry.js CHANGED
@@ -285,6 +285,7 @@ export function createFerry({ ctx, config, transfer, log = () => {} }) {
285
285
  {
286
286
  sessionId,
287
287
  eventCount: description.session.eventCount,
288
+ conversationCount: description.session.conversationCount,
288
289
  createdAt: description.session.createdAt,
289
290
  formatVersion: description.session.formatVersion,
290
291
  sourceRoot: description.session.cwd,
@@ -362,6 +363,7 @@ export function createFerry({ ctx, config, transfer, log = () => {} }) {
362
363
  // With a fresh id, an id collision on the peer is not a conflict.
363
364
  sessionId: forkId ? undefined : sessionId,
364
365
  eventCount: description.session.eventCount,
366
+ conversationCount: description.session.conversationCount,
365
367
  createdAt: description.session.createdAt,
366
368
  formatVersion: description.session.formatVersion,
367
369
  // No target means the receiver applies its own mapping: reuse the
@@ -463,6 +465,7 @@ export function createFerry({ ctx, config, transfer, log = () => {} }) {
463
465
  const local = await transfer.preflight({
464
466
  sessionId,
465
467
  eventCount: description.session.eventCount,
468
+ conversationCount: description.session.conversationCount,
466
469
  createdAt: description.session.createdAt,
467
470
  formatVersion: description.session.formatVersion,
468
471
  sourceRoot: description.session.cwd,
@@ -513,6 +516,7 @@ export function createFerry({ ctx, config, transfer, log = () => {} }) {
513
516
  const local = await transfer.preflight({
514
517
  sessionId: forkId ? undefined : sessionId,
515
518
  eventCount: description.session.eventCount,
519
+ conversationCount: description.session.conversationCount,
516
520
  createdAt: description.session.createdAt,
517
521
  formatVersion: description.session.formatVersion,
518
522
  targetDir,
package/lib/routes.js CHANGED
@@ -106,6 +106,7 @@ export function createPeerHandler({ config, ferry, transfer, log = () => {} }) {
106
106
  const decision = await transfer.preflight({
107
107
  sessionId: typeof body.sessionId === 'string' ? body.sessionId : undefined,
108
108
  eventCount: typeof body.eventCount === 'number' ? body.eventCount : undefined,
109
+ conversationCount: typeof body.conversationCount === 'number' ? body.conversationCount : undefined,
109
110
  createdAt: typeof body.createdAt === 'number' ? body.createdAt : undefined,
110
111
  formatVersion: typeof body.formatVersion === 'number' ? body.formatVersion : undefined,
111
112
  sourceRoot: typeof body.sourceRoot === 'string' ? body.sourceRoot : undefined,
package/lib/state.js CHANGED
@@ -10,6 +10,13 @@
10
10
  */
11
11
  import { createHash } from 'node:crypto';
12
12
 
13
+ /**
14
+ * Payload fields that hold a sequence number pointing at another event. They
15
+ * move when a log is renumbered, so they are compared as "a reference exists"
16
+ * rather than by value.
17
+ */
18
+ const SEQ_REFERENCE_KEYS = new Set(['headerSeq', 'sourceEventSeq', 'shadowedSeqs', 'shadowedRange']);
19
+
13
20
  /** Number of trailing events that make up a tail fingerprint. */
14
21
  export const TAIL_EVENTS = 8;
15
22
 
@@ -63,7 +70,10 @@ function updateHash(hash, value) {
63
70
  for (const key of Object.keys(record).sort()) {
64
71
  hash.update(JSON.stringify(key));
65
72
  hash.update(':');
66
- updateHash(hash, record[key]);
73
+ // Sequence references are derived positions, not content: an append that
74
+ // renumbers its tail must not read as a different conversation.
75
+ if (SEQ_REFERENCE_KEYS.has(key)) hash.update('"#seq"');
76
+ else updateHash(hash, record[key]);
67
77
  hash.update(',');
68
78
  }
69
79
  hash.update('}');
@@ -121,11 +131,15 @@ export function conversationEvents(events) {
121
131
  * @returns {object} event as compared.
122
132
  */
123
133
  function comparableEvent(event) {
124
- if (event !== null && typeof event === 'object' && event.type === 'session/end-seed' && 'time' in event) {
125
- const { time: _time, ...rest } = event;
126
- return rest;
134
+ if (event === null || typeof event !== 'object') return event;
135
+ // Position is the array index; the stored number moves when a tail is
136
+ // renumbered onto a peer's log, so it must not decide equality.
137
+ const { seq: _seq, ...rest } = event;
138
+ if (rest.type === 'session/end-seed' && 'time' in rest) {
139
+ const { time: _time, ...withoutTime } = rest;
140
+ return withoutTime;
127
141
  }
128
- return event;
142
+ return rest;
129
143
  }
130
144
 
131
145
  /**
package/lib/transfer.js CHANGED
@@ -276,6 +276,7 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
276
276
  }
277
277
  const records = await ctx.sessionPersistence.list({ signal });
278
278
  const children = records.filter((record) => record.header.parentSession === sessionId).length;
279
+ const conversation = conversationEvents(snapshot.events);
279
280
  return {
280
281
  session: {
281
282
  sessionId,
@@ -283,7 +284,8 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
283
284
  cwd: header.cwd,
284
285
  createdAt: header.createdAt,
285
286
  eventCount: snapshot.events.length,
286
- tail: tailOf(snapshot.events),
287
+ conversationCount: conversation.length,
288
+ tail: tailOf(conversation),
287
289
  inheritedEventCount: snapshot.inheritedEventCount,
288
290
  origin: header.origin,
289
291
  agentPreset: header.agentPreset,
@@ -442,7 +444,8 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
442
444
  header,
443
445
  inheritedEventCount: snapshot.inheritedEventCount,
444
446
  eventCount: snapshot.events.length,
445
- tail: tailOf(snapshot.events),
447
+ conversationCount: conversationEvents(snapshot.events).length,
448
+ tail: tailOf(conversationEvents(snapshot.events)),
446
449
  lastSeq: snapshot.events.at(-1)?.seq ?? -1,
447
450
  agentPreset: header.agentPreset,
448
451
  origin: header.origin,
@@ -504,9 +507,15 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
504
507
  );
505
508
  }
506
509
  const local = await readSnapshot(sessionId, signal);
507
- const remoteCount = typeof sourceSession.eventCount === 'number' ? sourceSession.eventCount : undefined;
510
+ const mine = conversationEvents(local.events);
511
+ const remoteCount =
512
+ typeof sourceSession.conversationCount === 'number'
513
+ ? sourceSession.conversationCount
514
+ : typeof sourceSession.eventCount === 'number'
515
+ ? sourceSession.eventCount
516
+ : undefined;
508
517
  const state = classifySession(
509
- { count: local.events.length, tail: tailOf(local.events) },
518
+ { count: mine.length, tail: tailOf(mine) },
510
519
  { count: remoteCount, tail: readTail(sourceSession.tail) },
511
520
  );
512
521
  if (state === 'same') return { mode: update ? 'update' : 'exists', snapshot: local };
@@ -751,6 +760,17 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
751
760
  workspaceId = workspace.id;
752
761
  if (plan.mode === 'update') {
753
762
  const shared = appendFrom;
763
+ // The receiver may hold more events than the sender at the same
764
+ // conversation position (it wrote its own runtime markers), so the
765
+ // appended batch is renumbered to continue the receiver's log.
766
+ const local = /** @type {{ events: object[] }} */ (plan.snapshot);
767
+ const sourceEvents = sessionPayload.events;
768
+ const nextSeq = local.events.length;
769
+ const delta = nextSeq - (sourceEvents[shared]?.seq ?? shared);
770
+ const tail = sourceEvents.slice(shared).map((event, index) => {
771
+ const seq = nextSeq + index;
772
+ return seq === event.seq ? event : renumberEvent(event, seq, delta, shared);
773
+ });
754
774
  let handle;
755
775
  try {
756
776
  handle = await ctx.sessionPersistence.open(sessionId, 'write', { signal });
@@ -767,10 +787,9 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
767
787
  throw error;
768
788
  }
769
789
  try {
770
- const events = sessionPayload.events;
771
- for (let offset = shared; offset < events.length; offset += 200) {
790
+ for (let offset = 0; offset < tail.length; offset += 200) {
772
791
  signal?.throwIfAborted();
773
- await handle.append(events.slice(offset, offset + 200), { signal });
792
+ await handle.append(tail.slice(offset, offset + 200), { signal });
774
793
  }
775
794
  await handle.flush({ signal });
776
795
  } finally {
@@ -792,7 +811,7 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
792
811
  sessionId,
793
812
  targetDir: target,
794
813
  workspaceId,
795
- appended: sessionPayload.events.length - shared,
814
+ appended: tail.length,
796
815
  events: sessionPayload.events.length,
797
816
  attachments: { rewritten: attachmentReplacements.size },
798
817
  workspace: {
@@ -886,6 +905,7 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
886
905
  targetDir,
887
906
  merge = false,
888
907
  tail,
908
+ conversationCount,
889
909
  }) => {
890
910
  const target = await resolveImportTarget({ requested: targetDir, sourceRoot, sessionId });
891
911
  const currentVersion = await currentFormatVersion();
@@ -923,10 +943,14 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
923
943
  }`,
924
944
  };
925
945
  }
926
- localCount = snapshot.events.length;
946
+ const conversation = conversationEvents(snapshot.events);
947
+ localCount = conversation.length;
927
948
  state = classifySession(
928
- { count: localCount, tail: tailOf(snapshot.events) },
929
- { count: eventCount, tail: readTail(tail) },
949
+ { count: localCount, tail: tailOf(conversation) },
950
+ {
951
+ count: typeof conversationCount === 'number' ? conversationCount : eventCount,
952
+ tail: readTail(tail),
953
+ },
930
954
  );
931
955
  if (state === 'unknown') {
932
956
  // A sender predating tail fingerprints only reports its creation time.
@@ -990,6 +1014,55 @@ async function* once(buffer) {
990
1014
  if (buffer !== undefined && buffer.length > 0) yield buffer;
991
1015
  }
992
1016
 
1017
+ /**
1018
+ * Rewrite one event for a log that numbers the same content differently.
1019
+ * @param {object} event - source event.
1020
+ * @param {number} seq - sequence number it takes in the receiver's log.
1021
+ * @param {number} delta - shift applied to the receiver-relative numbering.
1022
+ * @param {number} from - first sender seq that belongs to the shifted tail.
1023
+ * @returns {object} renumbered event.
1024
+ */
1025
+ function renumberEvent(event, seq, delta, from) {
1026
+ const next = { ...event, seq };
1027
+ if (event.data !== null && typeof event.data === 'object') {
1028
+ next.data = shiftSeqRefs(event.data, delta, from);
1029
+ }
1030
+ return next;
1031
+ }
1032
+
1033
+ /**
1034
+ * Shift the sequence references a payload may hold into the receiver's numbering.
1035
+ * @param {unknown} value - payload value.
1036
+ * @param {number} delta - shift for references inside the moved tail.
1037
+ * @param {number} from - first sender seq that belongs to the moved tail.
1038
+ * @returns {unknown} rewritten value.
1039
+ */
1040
+ function shiftSeqRefs(value, delta, from) {
1041
+ if (Array.isArray(value)) return value.map((item) => shiftSeqRefs(item, delta, from));
1042
+ if (value === null || typeof value !== 'object') return value;
1043
+ const out = {};
1044
+ for (const [key, item] of Object.entries(value)) {
1045
+ if ((key === 'headerSeq' || key === 'sourceEventSeq') && Number.isSafeInteger(item) && item >= from) {
1046
+ out[key] = item + delta;
1047
+ continue;
1048
+ }
1049
+ if (key === 'shadowedSeqs' && Array.isArray(item)) {
1050
+ out[key] = item.map((entry) => (Number.isSafeInteger(entry) && entry >= from ? entry + delta : entry));
1051
+ continue;
1052
+ }
1053
+ if (key === 'shadowedRange' && item !== null && typeof item === 'object') {
1054
+ out[key] = {
1055
+ ...item,
1056
+ ...(Number.isSafeInteger(item.start) && item.start >= from ? { start: item.start + delta } : {}),
1057
+ ...(Number.isSafeInteger(item.end) && item.end >= from ? { end: item.end + delta } : {}),
1058
+ };
1059
+ continue;
1060
+ }
1061
+ out[key] = shiftSeqRefs(item, delta, from);
1062
+ }
1063
+ return out;
1064
+ }
1065
+
993
1066
  /**
994
1067
  * Raw index in the incoming log where the receiver's new events begin.
995
1068
  * @param {readonly object[]} incoming - incoming session events.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ferry",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Move Sessions and their Workspace between DSH instances over any reachable network.",
6
6
  "license": "MIT",