@artenx/dshb 0.1.21 → 0.1.23

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.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/lib/auth/index.js +22 -11
  3. package/lib/auth/index.js.map +1 -1
  4. package/lib/client.js +1 -1
  5. package/lib/client.js.map +1 -1
  6. package/lib/core/index.js +2 -2
  7. package/lib/core/index.js.map +1 -1
  8. package/lib/core/sync/coordinator.d.ts +2 -2
  9. package/lib/core/sync/coordinator.js +121 -15
  10. package/lib/core/sync/coordinator.js.map +1 -1
  11. package/lib/core/sync/internal-routes.d.ts +1 -1
  12. package/lib/core/sync/internal-routes.js +58 -9
  13. package/lib/core/sync/internal-routes.js.map +1 -1
  14. package/lib/core/sync/offline-archive.d.ts +1 -1
  15. package/lib/core/sync/offline-archive.js +1 -1
  16. package/lib/core/sync/offline-routes.js +1 -1
  17. package/lib/core/sync/protocol.d.ts +2 -2
  18. package/lib/core/sync/protocol.js +4 -1
  19. package/lib/core/sync/protocol.js.map +1 -1
  20. package/lib/core/sync/remote-helper.js +10 -4
  21. package/lib/core/sync/remote-helper.js.map +1 -1
  22. package/lib/core/sync/routes.d.ts +1 -1
  23. package/lib/core/sync/session-routes.d.ts +1 -1
  24. package/lib/core/sync/session-routes.js +1 -1
  25. package/lib/core/sync/session-sync.d.ts +2 -2
  26. package/lib/core/sync/session-sync.js +2 -2
  27. package/lib/core/sync/ssh-transport.d.ts +2 -2
  28. package/lib/core/sync/ssh-transport.js +97 -104
  29. package/lib/core/sync/ssh-transport.js.map +1 -1
  30. package/lib/{protocol-CV7LGE44.d.ts → protocol-KaWZArY8.d.ts} +5 -2
  31. package/lib/{session-sync-EdfwUON5.js → session-sync-C0uoizyB.js} +834 -98
  32. package/lib/session-sync-C0uoizyB.js.map +1 -0
  33. package/lib/{session-sync-g6qKZzjV.d.ts → session-sync-C2j2rk9K.d.ts} +56 -4
  34. package/lib/{ssh-transport-BcIiLxsX.d.ts → ssh-transport-DGkQh-HA.d.ts} +14 -5
  35. package/package.json +1 -1
  36. package/lib/session-sync-EdfwUON5.js.map +0 -1
@@ -4,10 +4,10 @@ import "./core/workspace-instructions.js";
4
4
  import { i as deepFreeze, r as brandString } from "./lib-gdtU_WKT.js";
5
5
  import { SyncSessionJournalStore } from "./core/sync/session-journal.js";
6
6
  import { MAX_SYNC_TRANSFER_BYTES } from "./core/sync/protocol.js";
7
- import { tmpdir } from "node:os";
8
- import { constants, mkdirSync } from "node:fs";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import { chmodSync, closeSync, constants, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
9
  import { basename, dirname, join, normalize, posix, resolve } from "node:path";
10
- import { createHash } from "node:crypto";
10
+ import { createHash, randomBytes } from "node:crypto";
11
11
  import "@deepseek-ai/cordis";
12
12
  import { lstat, mkdir, open, realpath, unlink } from "node:fs/promises";
13
13
  //#region ../../node_modules/@deepseek-ai/dsh-session/lib/index.js
@@ -392,9 +392,105 @@ function assertContiguous(id, events, cursor) {
392
392
  for (const [index, event] of events.entries()) if (event.seq !== cursor + index) throw new Error(`append seq mismatch for "${id}": expected ${cursor + index} at index ${index}, got ${event.seq}`);
393
393
  }
394
394
  //#endregion
395
+ //#region src/core/sync/export-cache.ts
396
+ const MAX_EXPORT_CACHE_FILE_BYTES = 8388608;
397
+ function cacheHome(home) {
398
+ return home ?? process.env.DSH_HOME ?? join(homedir(), ".dsh");
399
+ }
400
+ function atomicWrite(file, content) {
401
+ const dir = dirname(file);
402
+ mkdirSync(dir, {
403
+ recursive: true,
404
+ mode: 448
405
+ });
406
+ const temporary = `${file}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
407
+ let fd;
408
+ try {
409
+ fd = openSync(temporary, "wx", 384);
410
+ writeFileSync(fd, content, "utf8");
411
+ fsyncSync(fd);
412
+ closeSync(fd);
413
+ fd = void 0;
414
+ renameSync(temporary, file);
415
+ chmodSync(file, 384);
416
+ } catch (error) {
417
+ if (fd !== void 0) closeSync(fd);
418
+ try {
419
+ unlinkSync(temporary);
420
+ } catch {}
421
+ throw error;
422
+ }
423
+ }
424
+ function validRevision(value) {
425
+ return isRecord$1(value) && isNonNegativeSafeInteger(value.physicalTimeMs) && isNonNegativeSafeInteger(value.logicalCounter) && typeof value.writerInstanceId === "string" && !!value.writerInstanceId;
426
+ }
427
+ function validEntry(value) {
428
+ if (!isRecord$1(value) || typeof value.fingerprint !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value.fingerprint) || !isRecord$1(value.metadata) || typeof value.metadata.contentDigest !== "string" || !validRevision(value.metadata.revision) || !isNonNegativeSafeInteger(value.eventCount) || typeof value.eventDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value.eventDigest) || !isNonNegativeSafeInteger(value.inheritedEventCount)) return false;
429
+ return true;
430
+ }
431
+ var SyncSessionExportCacheStore = class {
432
+ filePath;
433
+ data;
434
+ dirty = false;
435
+ constructor(home) {
436
+ this.filePath = join(cacheHome(home), "dshb", "sync-export-cache.json");
437
+ }
438
+ get(key) {
439
+ return this.load().entries[key];
440
+ }
441
+ set(key, entry) {
442
+ if (!validEntry(entry)) return;
443
+ this.load().entries[key] = entry;
444
+ this.dirty = true;
445
+ }
446
+ delete(key) {
447
+ if (!(key in this.load().entries)) return;
448
+ delete this.load().entries[key];
449
+ this.dirty = true;
450
+ }
451
+ flush() {
452
+ if (!this.dirty || !this.data) return;
453
+ this.dirty = false;
454
+ try {
455
+ atomicWrite(this.filePath, `${JSON.stringify(this.data)}\n`);
456
+ } catch {
457
+ this.dirty = true;
458
+ }
459
+ }
460
+ entries() {
461
+ return Object.entries(this.load().entries);
462
+ }
463
+ load() {
464
+ if (this.data) return this.data;
465
+ let restored = {
466
+ version: 1,
467
+ entries: {}
468
+ };
469
+ try {
470
+ const raw = readFileSync(this.filePath, "utf8");
471
+ if (raw.length <= MAX_EXPORT_CACHE_FILE_BYTES) {
472
+ const parsed = JSON.parse(raw);
473
+ if (isRecord$1(parsed) && parsed.version === 1 && isRecord$1(parsed.entries)) {
474
+ const entries = {};
475
+ for (const [key, entry] of Object.entries(parsed.entries)) if (/^\[".+?",(null|".*?")\]$/.test(key) && validEntry(entry)) entries[key] = entry;
476
+ restored = {
477
+ version: 1,
478
+ entries
479
+ };
480
+ }
481
+ }
482
+ } catch {}
483
+ this.data = restored;
484
+ return restored;
485
+ }
486
+ };
487
+ //#endregion
395
488
  //#region src/core/sync/session-sync.ts
396
489
  const SESSION_READ_PAGE_SIZE = 256;
397
490
  const MAX_SYNC_SESSIONS = 1e4;
491
+ const MAX_MANIFEST_PAYLOAD_CACHE_BYTES = 4194304;
492
+ const MAX_MANIFEST_PAYLOAD_CACHE_COUNT = 8;
493
+ const SESSION_EXPORT_CACHE_VERSION = 1;
398
494
  const SPILL_META_VERSION = 1;
399
495
  const SPILL_ARTIFACT_META_KEY = "@deepseek-ai/dsh-spill";
400
496
  const LEGACY_LOCAL_SPILL_HINT = "Use read with offset/limit, or grep this path to search within it.";
@@ -408,6 +504,27 @@ var SyncSessionError = class extends Error {
408
504
  this.name = "SyncSessionError";
409
505
  }
410
506
  };
507
+ async function readStableSessionMetadata(persistence, sessionId, firstSnapshot) {
508
+ const handle = await persistence.open(sessionId, "read");
509
+ try {
510
+ const snapshot = await persistence.stat(sessionId);
511
+ if (!snapshot || snapshot.formatStatus === "migration-required") return void 0;
512
+ try {
513
+ if (canonicalJson(firstSnapshot.revision) !== canonicalJson(snapshot.revision) || canonicalJson(firstSnapshot.header) !== canonicalJson(snapshot.header) || canonicalJson(handle.header) !== canonicalJson(snapshot.header)) return void 0;
514
+ } catch {
515
+ return;
516
+ }
517
+ const inheritedEventCount = Number(handle.inheritedEventCount);
518
+ if (!Number.isSafeInteger(inheritedEventCount) || inheritedEventCount < 0) return void 0;
519
+ return {
520
+ snapshot,
521
+ header: handle.header,
522
+ inheritedEventCount
523
+ };
524
+ } finally {
525
+ await handle.close();
526
+ }
527
+ }
411
528
  function spillSessionDirectoryName(sessionId) {
412
529
  return `session-${createHash("sha256").update(sessionId).digest("hex").slice(0, 12)}`;
413
530
  }
@@ -770,6 +887,9 @@ function sessionEventDigest(header, inheritedEventCount, events, workspace, inst
770
887
  } : void 0
771
888
  }));
772
889
  }
890
+ function sessionEventListDigest(events) {
891
+ return sha256(canonicalJson(events));
892
+ }
773
893
  function sessionStateDigest(workspace, workspacePosition, archived, pinned) {
774
894
  return sha256(canonicalJson({
775
895
  workspace: {
@@ -786,6 +906,70 @@ function sessionStateDigest(workspace, workspacePosition, archived, pinned) {
786
906
  pinned
787
907
  }));
788
908
  }
909
+ function sessionExportFingerprint(input) {
910
+ if (input.revision === void 0) return void 0;
911
+ try {
912
+ return sha256(canonicalJson({
913
+ version: SESSION_EXPORT_CACHE_VERSION,
914
+ ...input
915
+ }));
916
+ } catch {
917
+ return;
918
+ }
919
+ }
920
+ function sessionExportCacheKey(sessionId, branchId) {
921
+ return JSON.stringify([sessionId, branchId ?? null]);
922
+ }
923
+ function estimateJsonBytes(value, limit) {
924
+ const active = /* @__PURE__ */ new WeakSet();
925
+ const measure = (item) => {
926
+ if (item === null) return 4;
927
+ if (typeof item === "string") {
928
+ const bytes = Buffer.byteLength(item, "utf8");
929
+ return bytes > limit / 6 ? limit + 1 : bytes * 6 + 2;
930
+ }
931
+ if (typeof item === "number") return Number.isFinite(item) ? String(item).length : 4;
932
+ if (typeof item === "boolean") return item ? 4 : 5;
933
+ if (item === void 0 || typeof item === "function" || typeof item === "symbol") return 4;
934
+ if (typeof item === "bigint") return limit + 1;
935
+ if (Array.isArray(item)) {
936
+ if (active.has(item)) return limit + 1;
937
+ active.add(item);
938
+ let bytes = 2;
939
+ for (let index = 0; index < item.length; index += 1) {
940
+ if (index > 0) bytes += 1;
941
+ bytes += measure(item[index]);
942
+ if (bytes > limit) break;
943
+ }
944
+ active.delete(item);
945
+ return bytes;
946
+ }
947
+ if (typeof item === "object") {
948
+ if (active.has(item)) return limit + 1;
949
+ const prototype = Object.getPrototypeOf(item);
950
+ if (prototype !== Object.prototype && prototype !== null) return limit + 1;
951
+ active.add(item);
952
+ let bytes = 2;
953
+ let first = true;
954
+ for (const [key, child] of Object.entries(item)) {
955
+ if (child === void 0 || typeof child === "function" || typeof child === "symbol") continue;
956
+ if (!first) bytes += 1;
957
+ first = false;
958
+ bytes += Buffer.byteLength(key, "utf8") * 6 + 3 + measure(child);
959
+ if (bytes > limit) break;
960
+ }
961
+ active.delete(item);
962
+ return bytes;
963
+ }
964
+ return limit + 1;
965
+ };
966
+ try {
967
+ const bytes = measure(value);
968
+ return bytes <= limit ? bytes : void 0;
969
+ } catch {
970
+ return;
971
+ }
972
+ }
789
973
  function planSessionTransfers(direction, localEntries, remoteEntries) {
790
974
  const toLocal = [];
791
975
  const toRemote = [];
@@ -852,15 +1036,65 @@ async function collectBytes(stream, maxBytes = MAX_SYNC_TRANSFER_BYTES) {
852
1036
  async function* oneChunk(data) {
853
1037
  yield data;
854
1038
  }
855
- function findSessionRecordByLocalId(state, localSessionId) {
856
- return Object.values(state.records).filter((metadata) => metadata.kind === "session" && metadata.localEntityId === localSessionId).sort((a, b) => compareSyncRevision(b.revision, a.revision))[0];
1039
+ function createSessionStateIndex(state) {
1040
+ const index = {
1041
+ state,
1042
+ recordsByLocalId: /* @__PURE__ */ new Map(),
1043
+ workspacesByLocalId: /* @__PURE__ */ new Map(),
1044
+ archivesByLocalSessionId: /* @__PURE__ */ new Map()
1045
+ };
1046
+ for (const metadata of Object.values(state.records)) {
1047
+ if (metadata.kind !== "session") continue;
1048
+ const current = index.recordsByLocalId.get(metadata.localEntityId);
1049
+ if (!current || compareSyncRevision(metadata.revision, current.revision) > 0) index.recordsByLocalId.set(metadata.localEntityId, metadata);
1050
+ }
1051
+ for (const mapping of Object.values(state.workspaces)) {
1052
+ const mappings = index.workspacesByLocalId.get(mapping.localWorkspaceId) ?? [];
1053
+ mappings.push(mapping);
1054
+ index.workspacesByLocalId.set(mapping.localWorkspaceId, mappings);
1055
+ }
1056
+ for (const archive of Object.values(state.archives)) if (!index.archivesByLocalSessionId.has(archive.localSessionId)) index.archivesByLocalSessionId.set(archive.localSessionId, archive);
1057
+ return index;
857
1058
  }
858
- function findSessionOriginByLocalId(state, localSessionId) {
859
- const metadata = findSessionRecordByLocalId(state, localSessionId);
860
- return metadata ? {
861
- originInstanceId: metadata.originInstanceId,
862
- originEntityId: metadata.originEntityId
863
- } : void 0;
1059
+ function indexSessionRecord(index, metadata) {
1060
+ const key = sessionOriginKey(metadata);
1061
+ const previous = index.state.records[key];
1062
+ if (previous && previous.localEntityId !== metadata.localEntityId && index.recordsByLocalId.get(previous.localEntityId) === previous) {
1063
+ delete index.state.records[key];
1064
+ index.recordsByLocalId.delete(previous.localEntityId);
1065
+ for (const candidate of Object.values(index.state.records)) {
1066
+ if (candidate.kind !== "session" || candidate.localEntityId !== previous.localEntityId) continue;
1067
+ const current = index.recordsByLocalId.get(previous.localEntityId);
1068
+ if (!current || compareSyncRevision(candidate.revision, current.revision) > 0) index.recordsByLocalId.set(previous.localEntityId, candidate);
1069
+ }
1070
+ }
1071
+ index.state.records[key] = metadata;
1072
+ const current = index.recordsByLocalId.get(metadata.localEntityId);
1073
+ if (current === previous || !current || compareSyncRevision(metadata.revision, current.revision) > 0) index.recordsByLocalId.set(metadata.localEntityId, metadata);
1074
+ }
1075
+ function indexWorkspaceMapping(index, key, mapping) {
1076
+ const previous = index.state.workspaces[key];
1077
+ if (previous) {
1078
+ const mappings = index.workspacesByLocalId.get(previous.localWorkspaceId);
1079
+ const previousIndex = mappings?.findIndex((item) => syncWorkspaceKey(item.originInstanceId, item.originWorkspaceId, item.remotePath) === key) ?? -1;
1080
+ if (mappings && previousIndex !== -1 && previous.localWorkspaceId === mapping.localWorkspaceId) {
1081
+ index.state.workspaces[key] = mapping;
1082
+ mappings[previousIndex] = mapping;
1083
+ return;
1084
+ }
1085
+ if (mappings && previousIndex !== -1) mappings.splice(previousIndex, 1);
1086
+ if (mappings?.length === 0) index.workspacesByLocalId.delete(previous.localWorkspaceId);
1087
+ }
1088
+ index.state.workspaces[key] = mapping;
1089
+ const mappings = index.workspacesByLocalId.get(mapping.localWorkspaceId) ?? [];
1090
+ mappings.push(mapping);
1091
+ index.workspacesByLocalId.set(mapping.localWorkspaceId, mappings);
1092
+ }
1093
+ function indexArchive(index, previousBranchId, archive) {
1094
+ delete index.state.archives[previousBranchId];
1095
+ index.state.archives[archive.branchId] = archive;
1096
+ index.archivesByLocalSessionId.clear();
1097
+ for (const entry of Object.values(index.state.archives)) if (!index.archivesByLocalSessionId.has(entry.localSessionId)) index.archivesByLocalSessionId.set(entry.localSessionId, entry);
864
1098
  }
865
1099
  function workspaceDigest(workspace) {
866
1100
  return sha256(canonicalJson(workspace));
@@ -879,14 +1113,27 @@ function validateObjectPayload(object) {
879
1113
  if (object.kind === "spill" && isRecord$1(object.reference) && isRecord$1(object.reference.owner) && typeof object.reference.owner.sessionId === "string" && isRecord$1(object.reference.ref) && typeof object.reference.ref.locator === "string" && object.id === spillKey(object.reference)) return object;
880
1114
  throw new SyncSessionError("object-invalid", "session object kind or reference is invalid");
881
1115
  }
1116
+ function validateEventDelta(value) {
1117
+ if (!isRecord$1(value) || !isNonNegativeSafeInteger(value.baseEventCount) || typeof value.baseEventDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value.baseEventDigest) || !isNonNegativeSafeInteger(value.baseInheritedEventCount) || !isNonNegativeSafeInteger(value.fullEventCount) || value.fullEventCount < value.baseEventCount || typeof value.fullEventDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value.fullEventDigest)) throw new SyncSessionError("manifest-invalid", "session event delta metadata is invalid");
1118
+ return {
1119
+ baseEventCount: value.baseEventCount,
1120
+ baseEventDigest: value.baseEventDigest,
1121
+ baseInheritedEventCount: value.baseInheritedEventCount,
1122
+ fullEventCount: value.fullEventCount,
1123
+ fullEventDigest: value.fullEventDigest
1124
+ };
1125
+ }
882
1126
  function validateEntry(entry) {
883
1127
  if (!isRecord$1(entry) || !isRecord$1(entry.metadata) || entry.metadata.kind !== "session") throw new SyncSessionError("manifest-invalid", "session entry metadata is invalid");
884
1128
  const metadata = entry.metadata;
885
1129
  if (typeof metadata.originInstanceId !== "string" || !metadata.originInstanceId || metadata.originInstanceId.length > 512 || /[\u0000-\u001f\u007f]/.test(metadata.originInstanceId) || typeof metadata.originEntityId !== "string" || !metadata.originEntityId || metadata.originEntityId.length > 512 || /[\u0000-\u001f\u007f]/.test(metadata.originEntityId) || typeof metadata.localEntityId !== "string" || !metadata.localEntityId || metadata.localEntityId.length > 512 || /[\u0000-\u001f\u007f]/.test(metadata.localEntityId) || typeof metadata.contentDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(metadata.contentDigest) || !metadata.revision || !isNonNegativeSafeInteger(metadata.revision.physicalTimeMs) || !isNonNegativeSafeInteger(metadata.revision.logicalCounter) || typeof metadata.revision.writerInstanceId !== "string" || !metadata.revision.writerInstanceId || /[\u0000-\u001f\u007f]/.test(metadata.revision.writerInstanceId)) throw new SyncSessionError("manifest-invalid", "session entry metadata fields are invalid");
886
1130
  if (metadata.stateDigest !== void 0 && !/^sha256:[a-f0-9]{64}$/.test(metadata.stateDigest)) throw new SyncSessionError("manifest-invalid", "session state digest is invalid");
1131
+ if (metadata.deleted !== void 0 && typeof metadata.deleted !== "boolean") throw new SyncSessionError("manifest-invalid", "session tombstone flag is invalid");
887
1132
  if (entry.branchId !== void 0 && (typeof entry.branchId !== "string" || !/^b-[a-f0-9]{32}$/.test(entry.branchId))) throw new SyncSessionError("manifest-invalid", "session branch ID is invalid");
888
1133
  if (metadata.workspaceOrigin !== void 0) validateOrigin(metadata.workspaceOrigin);
1134
+ const eventDelta = entry.eventDelta === void 0 ? void 0 : validateEventDelta(entry.eventDelta);
889
1135
  if (metadata.deleted) {
1136
+ if (eventDelta) throw new SyncSessionError("manifest-invalid", "session tombstones cannot carry event deltas");
890
1137
  if (metadata.contentDigest !== sessionTombstoneDigest(metadata)) throw new SyncSessionError("manifest-invalid", "session tombstone digest is invalid");
891
1138
  return {
892
1139
  metadata,
@@ -896,11 +1143,19 @@ function validateEntry(entry) {
896
1143
  const workspace = isRecord$1(entry.workspace) ? entry.workspace : void 0;
897
1144
  const workspaceRevision = workspace && isRecord$1(workspace.revision) ? workspace.revision : void 0;
898
1145
  if (typeof entry.sourceLocalSessionId !== "string" || !isRecord$1(entry.header) || typeof entry.inheritedEventCount !== "number" || !Number.isSafeInteger(entry.inheritedEventCount) || entry.inheritedEventCount < 0 || !Array.isArray(entry.events) || !workspace || !Array.isArray(entry.objects) || typeof workspace.originInstanceId !== "string" || typeof workspace.originWorkspaceId !== "string" || !isRecord$1(workspace.nodeOrigin) || typeof workspace.nodeOrigin.originInstanceId !== "string" || typeof workspace.nodeOrigin.originEntityId !== "string" || typeof workspace.remotePath !== "string" || !posix.isAbsolute(workspace.remotePath) || posix.normalize(workspace.remotePath) !== workspace.remotePath || typeof workspace.title !== "string" || typeof workspace.contentDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(workspace.contentDigest) || !workspaceRevision || !isNonNegativeSafeInteger(workspaceRevision.physicalTimeMs) || !isNonNegativeSafeInteger(workspaceRevision.logicalCounter) || typeof workspaceRevision.writerInstanceId !== "string" || typeof entry.workspacePosition !== "number" || !Number.isSafeInteger(entry.workspacePosition) || entry.workspacePosition < 0 || typeof entry.archived !== "boolean" || typeof entry.pinned !== "boolean") throw new SyncSessionError("manifest-invalid", "active session entry fields are invalid");
1146
+ if (eventDelta && (entry.branchId !== void 0 || entry.archived)) throw new SyncSessionError("manifest-invalid", "archives cannot use event tail deltas");
899
1147
  if (entry.events.length > MAX_SYNC_SESSIONS * 100) throw new SyncSessionError("manifest-invalid", "session event count exceeds limit");
900
1148
  const header = entry.header;
901
1149
  if (header.version !== 4 || header.id !== entry.sourceLocalSessionId) throw new SyncSessionError("session-format", "session header format or id is invalid");
902
- const events = validateStoredEvents(header, structuredClone(entry.events));
903
- assertContiguous(header.id, events, 0);
1150
+ let events;
1151
+ if (eventDelta) {
1152
+ if (eventDelta.baseInheritedEventCount !== entry.inheritedEventCount || eventDelta.fullEventCount > MAX_SYNC_SESSIONS * 100 || eventDelta.fullEventCount - eventDelta.baseEventCount !== entry.events.length) throw new SyncSessionError("manifest-invalid", "session event delta counts are inconsistent");
1153
+ events = structuredClone(entry.events);
1154
+ for (let index = 0; index < events.length; index += 1) if (!isNonNegativeSafeInteger(events[index]?.seq) || events[index]?.seq !== eventDelta.baseEventCount + index) throw new SyncSessionError("manifest-invalid", "session event delta sequence is invalid");
1155
+ } else {
1156
+ events = validateStoredEvents(header, structuredClone(entry.events));
1157
+ assertContiguous(header.id, events, 0);
1158
+ }
904
1159
  const objects = entry.objects.map(validateObjectPayload);
905
1160
  if (new Set(objects.map((object) => object.id)).size !== objects.length) throw new SyncSessionError("manifest-invalid", "session entry contains duplicate objects");
906
1161
  const workspaceValue = workspace;
@@ -918,9 +1173,11 @@ function validateEntry(entry) {
918
1173
  for (const ref of attachments.files) referencedObjects.add(attachmentKey("file", ref));
919
1174
  for (const reference of [...spillReferences(events), ...legacySpillReferences(events, String(header.id))]) referencedObjects.add(spillKey(reference));
920
1175
  const objectIds = new Set(objects.map((object) => object.id));
921
- if ([...referencedObjects].some((id) => !objectIds.has(id)) || [...objectIds].some((id) => !referencedObjects.has(id))) throw new SyncSessionError("manifest-invalid", "session object references and payloads do not match");
1176
+ if ([...referencedObjects].some((id) => !objectIds.has(id)) || !eventDelta && [...objectIds].some((id) => !referencedObjects.has(id))) throw new SyncSessionError("manifest-invalid", "session object references and payloads do not match");
922
1177
  const parentOrigin = entry.parentOrigin === void 0 ? void 0 : validateOrigin(entry.parentOrigin);
923
- if (sessionEventDigest(header, entry.inheritedEventCount, events, workspaceValue, instructionSnapshot, objects, parentOrigin) !== metadata.contentDigest) throw new SyncSessionError("manifest-invalid", "session content digest is invalid");
1178
+ if (!eventDelta) {
1179
+ if (sessionEventDigest(header, entry.inheritedEventCount, events, workspaceValue, instructionSnapshot, objects, parentOrigin) !== metadata.contentDigest) throw new SyncSessionError("manifest-invalid", "session content digest is invalid");
1180
+ }
924
1181
  if (metadata.stateDigest !== sessionStateDigest(workspaceValue, entry.workspacePosition, entry.archived, entry.pinned)) throw new SyncSessionError("manifest-invalid", "session state digest is invalid");
925
1182
  return {
926
1183
  metadata,
@@ -935,21 +1192,54 @@ function validateEntry(entry) {
935
1192
  pinned: entry.pinned,
936
1193
  ...instructionSnapshot ? { instructionSnapshot } : {},
937
1194
  events,
938
- objects
1195
+ objects,
1196
+ ...eventDelta ? { eventDelta } : {}
1197
+ };
1198
+ }
1199
+ function validateManifestEntry(entry) {
1200
+ if (!isRecord$1(entry) || !isRecord$1(entry.metadata) || entry.metadata.kind !== "session") throw new SyncSessionError("manifest-invalid", "session manifest entry metadata is invalid");
1201
+ const metadata = entry.metadata;
1202
+ if (typeof metadata.originInstanceId !== "string" || !metadata.originInstanceId || metadata.originInstanceId.length > 512 || /[\u0000-\u001f\u007f]/.test(metadata.originInstanceId) || typeof metadata.originEntityId !== "string" || !metadata.originEntityId || metadata.originEntityId.length > 512 || /[\u0000-\u001f\u007f]/.test(metadata.originEntityId) || typeof metadata.localEntityId !== "string" || !metadata.localEntityId || metadata.localEntityId.length > 512 || /[\u0000-\u001f\u007f]/.test(metadata.localEntityId) || typeof metadata.contentDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(metadata.contentDigest) || !metadata.revision || !isNonNegativeSafeInteger(metadata.revision.physicalTimeMs) || !isNonNegativeSafeInteger(metadata.revision.logicalCounter) || typeof metadata.revision.writerInstanceId !== "string" || !metadata.revision.writerInstanceId || /[\u0000-\u001f\u007f]/.test(metadata.revision.writerInstanceId)) throw new SyncSessionError("manifest-invalid", "session manifest metadata fields are invalid");
1203
+ if (metadata.stateDigest !== void 0 && !/^sha256:[a-f0-9]{64}$/.test(metadata.stateDigest)) throw new SyncSessionError("manifest-invalid", "session manifest state digest is invalid");
1204
+ if (metadata.deleted !== void 0 && typeof metadata.deleted !== "boolean") throw new SyncSessionError("manifest-invalid", "session manifest tombstone flag is invalid");
1205
+ const hasEventCount = entry.eventCount !== void 0;
1206
+ const hasEventDigest = entry.eventDigest !== void 0;
1207
+ const hasInheritedEventCount = entry.inheritedEventCount !== void 0;
1208
+ if (hasEventCount || hasEventDigest || hasInheritedEventCount) {
1209
+ if (!hasEventCount || !hasEventDigest || !hasInheritedEventCount || metadata.deleted || !isNonNegativeSafeInteger(entry.eventCount) || entry.eventCount > MAX_SYNC_SESSIONS * 100 || typeof entry.eventDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(entry.eventDigest) || !isNonNegativeSafeInteger(entry.inheritedEventCount) || entry.inheritedEventCount > entry.eventCount) throw new SyncSessionError("manifest-invalid", "session event summary is invalid");
1210
+ }
1211
+ if (entry.branchId !== void 0 && (typeof entry.branchId !== "string" || !/^b-[a-f0-9]{32}$/.test(entry.branchId))) throw new SyncSessionError("manifest-invalid", "session manifest branch ID is invalid");
1212
+ if (metadata.workspaceOrigin !== void 0) validateOrigin(metadata.workspaceOrigin);
1213
+ if (metadata.deleted && metadata.contentDigest !== sessionTombstoneDigest(metadata)) throw new SyncSessionError("manifest-invalid", "session manifest tombstone digest is invalid");
1214
+ return {
1215
+ metadata,
1216
+ ...entry.branchId ? { branchId: entry.branchId } : {},
1217
+ ...hasEventCount ? { eventCount: entry.eventCount } : {},
1218
+ ...hasEventDigest ? { eventDigest: entry.eventDigest } : {},
1219
+ ...hasInheritedEventCount ? { inheritedEventCount: entry.inheritedEventCount } : {}
939
1220
  };
940
1221
  }
941
1222
  function bundleDigest(bundle) {
1223
+ const sharedObjects = new Map((bundle.objects ?? []).map((object) => [object.id, object]));
942
1224
  const manifest = bundle.entries.map((entry) => ({
943
1225
  key: entry.branchId ?? sessionOriginKey(entry.metadata),
944
1226
  revision: entry.metadata.revision,
945
1227
  contentDigest: entry.metadata.contentDigest,
946
1228
  stateDigest: entry.metadata.stateDigest,
947
1229
  deleted: Boolean(entry.metadata.deleted),
948
- objects: (entry.objects ?? []).map((object) => ({
1230
+ objects: (entry.objects ?? (entry.sharedObjectIds ?? []).map((id) => {
1231
+ const object = sharedObjects.get(id);
1232
+ if (!object) throw new SyncSessionError("manifest-invalid", "session shared object id is unknown");
1233
+ return object;
1234
+ })).map((object) => ({
949
1235
  id: object.id,
950
1236
  digest: object.digest,
951
1237
  bytes: object.bytes
952
- })).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
1238
+ })).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
1239
+ eventDelta: entry.eventDelta === void 0 ? void 0 : {
1240
+ ...entry.eventDelta,
1241
+ suffixDigest: sessionEventListDigest(entry.events ?? [])
1242
+ }
953
1243
  })).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
954
1244
  const skipped = bundle.skipped.map((entry) => ({
955
1245
  sessionId: entry.sessionId,
@@ -961,12 +1251,36 @@ function bundleDigest(bundle) {
961
1251
  skipped
962
1252
  }));
963
1253
  }
1254
+ function materializeSharedObjects(bundle) {
1255
+ const shared = bundle.objects === void 0 ? /* @__PURE__ */ new Map() : new Map(bundle.objects.map((object) => {
1256
+ const validated = validateObjectPayload(object);
1257
+ return [validated.id, validated];
1258
+ }));
1259
+ if (shared.size !== (bundle.objects?.length ?? 0)) throw new SyncSessionError("manifest-invalid", "session bundle contains duplicate shared objects");
1260
+ for (const entry of bundle.entries) {
1261
+ if (!entry || typeof entry !== "object") continue;
1262
+ if (entry.sharedObjectIds !== void 0 && (!Array.isArray(entry.sharedObjectIds) || new Set(entry.sharedObjectIds).size !== entry.sharedObjectIds.length || entry.sharedObjectIds.some((id) => !shared.has(id)))) throw new SyncSessionError("manifest-invalid", "session entry shared object references are invalid");
1263
+ }
1264
+ return shared;
1265
+ }
964
1266
  function validateSyncSessionBundle(value) {
965
1267
  if (!isRecord$1(value) || value.protocolVersion !== 1 || typeof value.instanceId !== "string" || !/^i-[a-f0-9]{32}$/.test(value.instanceId) || !Array.isArray(value.entries) || typeof value.digest !== "string") throw new SyncSessionError("manifest-invalid", "session bundle header is invalid");
1268
+ if (value.objects !== void 0 && (!Array.isArray(value.objects) || value.objects.some((object) => !isRecord$1(object)))) throw new SyncSessionError("manifest-invalid", "session bundle shared object table is invalid");
1269
+ const sharedObjects = materializeSharedObjects({
1270
+ ...value.objects === void 0 ? {} : { objects: value.objects },
1271
+ entries: value.entries
1272
+ });
966
1273
  const skipped = value.skipped === void 0 ? [] : value.skipped;
967
1274
  if (!Array.isArray(skipped) || skipped.some((item) => !isRecord$1(item) || typeof item.sessionId !== "string" || typeof item.reason !== "string")) throw new SyncSessionError("manifest-invalid", "session bundle skip report is invalid");
968
1275
  if (value.entries.length > MAX_SYNC_SESSIONS) throw new SyncSessionError("manifest-invalid", "session bundle contains too many records");
969
- const entries = value.entries.map(validateEntry);
1276
+ const entries = value.entries.map((entry) => {
1277
+ const materialized = { ...entry };
1278
+ if (materialized.sharedObjectIds !== void 0) {
1279
+ if (materialized.objects !== void 0) throw new SyncSessionError("manifest-invalid", "session entry cannot carry inline and shared objects");
1280
+ materialized.objects = materialized.sharedObjectIds.map((id) => sharedObjects.get(id));
1281
+ }
1282
+ return validateEntry(materialized);
1283
+ });
970
1284
  const keys = entries.map((entry) => entry.branchId ?? sessionOriginKey(entry.metadata));
971
1285
  if (new Set(keys).size !== keys.length) throw new SyncSessionError("manifest-invalid", "session bundle contains duplicate records");
972
1286
  const bundle = {
@@ -974,13 +1288,35 @@ function validateSyncSessionBundle(value) {
974
1288
  instanceId: value.instanceId,
975
1289
  entries,
976
1290
  skipped,
1291
+ ...value.objects === void 0 ? {} : { objects: value.objects },
977
1292
  digest: value.digest
978
1293
  };
979
1294
  if (bundleDigest(bundle) !== bundle.digest) throw new SyncSessionError("manifest-digest", "session bundle digest does not match");
980
1295
  if (Buffer.byteLength(JSON.stringify(bundle), "utf8") > 107374182400) throw new SyncSessionError("bundle-too-large", "session bundle exceeds the transfer limit");
981
1296
  return bundle;
982
1297
  }
983
- function makeSyncSessionBundle(instanceId, entries, skipped = []) {
1298
+ function makeSyncSessionBundle(instanceId, entries, skipped = [], sharedObjects) {
1299
+ if (sharedObjects !== void 0) {
1300
+ const referencesShared = entries.some((entry) => (entry.objects?.length ?? 0) > 0 || entry.sharedObjectIds !== void 0);
1301
+ const bundle = {
1302
+ protocolVersion: 1,
1303
+ instanceId,
1304
+ entries: referencesShared ? entries.map((entry) => {
1305
+ if (entry.objects === void 0) return entry;
1306
+ const { objects, ...rest } = entry;
1307
+ return {
1308
+ ...rest,
1309
+ sharedObjectIds: objects.map((object) => object.id)
1310
+ };
1311
+ }) : entries,
1312
+ skipped,
1313
+ ...referencesShared ? { objects: sharedObjects } : {},
1314
+ digest: ""
1315
+ };
1316
+ bundle.digest = bundleDigest(bundle);
1317
+ if (Buffer.byteLength(JSON.stringify(bundle), "utf8") > 107374182400) throw new SyncSessionError("bundle-too-large", "session bundle exceeds the transfer limit");
1318
+ return bundle;
1319
+ }
984
1320
  const bundle = {
985
1321
  protocolVersion: 1,
986
1322
  instanceId,
@@ -992,6 +1328,80 @@ function makeSyncSessionBundle(instanceId, entries, skipped = []) {
992
1328
  if (Buffer.byteLength(JSON.stringify(bundle), "utf8") > 107374182400) throw new SyncSessionError("bundle-too-large", "session bundle exceeds the transfer limit");
993
1329
  return bundle;
994
1330
  }
1331
+ function syncSessionEntryKey(entry) {
1332
+ return entry.branchId ?? sessionOriginKey(entry.metadata);
1333
+ }
1334
+ function withEventTail(entry, prefix) {
1335
+ if (!prefix || entry.metadata.deleted || entry.branchId || !entry.events || entry.events.length === 0 || prefix.eventCount === 0 || prefix.eventCount > entry.events.length || prefix.inheritedEventCount !== entry.inheritedEventCount) return entry;
1336
+ if (sessionEventListDigest(entry.events.slice(0, prefix.eventCount)) !== prefix.eventDigest) return entry;
1337
+ const fullEventDigest = entry.eventDigest !== void 0 && entry.eventCount === entry.events.length ? entry.eventDigest : sessionEventListDigest(entry.events);
1338
+ return {
1339
+ ...entry,
1340
+ eventDelta: {
1341
+ baseEventCount: prefix.eventCount,
1342
+ baseEventDigest: prefix.eventDigest,
1343
+ baseInheritedEventCount: prefix.inheritedEventCount,
1344
+ fullEventCount: entry.events.length,
1345
+ fullEventDigest
1346
+ },
1347
+ events: entry.events.slice(prefix.eventCount)
1348
+ };
1349
+ }
1350
+ function sessionManifestDigest(manifest) {
1351
+ const entries = manifest.entries.map((entry) => ({
1352
+ key: syncSessionEntryKey(entry),
1353
+ metadata: entry.metadata,
1354
+ eventCount: entry.eventCount,
1355
+ eventDigest: entry.eventDigest,
1356
+ inheritedEventCount: entry.inheritedEventCount
1357
+ })).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
1358
+ const skipped = manifest.skipped.map((entry) => ({
1359
+ sessionId: entry.sessionId,
1360
+ reason: entry.reason
1361
+ })).sort((a, b) => a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0);
1362
+ return sha256(canonicalJson({
1363
+ instanceId: manifest.instanceId,
1364
+ entries,
1365
+ skipped
1366
+ }));
1367
+ }
1368
+ function makeSyncSessionManifest(instanceId, entries, skipped = []) {
1369
+ const manifest = {
1370
+ protocolVersion: 1,
1371
+ instanceId,
1372
+ entries: entries.map((entry) => ({
1373
+ metadata: entry.metadata,
1374
+ ...entry.branchId ? { branchId: entry.branchId } : {},
1375
+ ...entry.eventCount === void 0 ? {} : { eventCount: entry.eventCount },
1376
+ ...entry.eventDigest === void 0 ? {} : { eventDigest: entry.eventDigest },
1377
+ ...entry.inheritedEventCount === void 0 ? {} : { inheritedEventCount: entry.inheritedEventCount }
1378
+ })),
1379
+ skipped,
1380
+ digest: ""
1381
+ };
1382
+ manifest.digest = sessionManifestDigest(manifest);
1383
+ if (Buffer.byteLength(JSON.stringify(manifest), "utf8") > 107374182400) throw new SyncSessionError("bundle-too-large", "session manifest exceeds the transfer limit");
1384
+ return manifest;
1385
+ }
1386
+ function validateSyncSessionManifest(value) {
1387
+ if (!isRecord$1(value) || value.protocolVersion !== 1 || typeof value.instanceId !== "string" || !/^i-[a-f0-9]{32}$/.test(value.instanceId) || !Array.isArray(value.entries) || typeof value.digest !== "string") throw new SyncSessionError("manifest-invalid", "session manifest header is invalid");
1388
+ const skipped = value.skipped === void 0 ? [] : value.skipped;
1389
+ if (!Array.isArray(skipped) || skipped.some((item) => !isRecord$1(item) || typeof item.sessionId !== "string" || typeof item.reason !== "string")) throw new SyncSessionError("manifest-invalid", "session manifest skip report is invalid");
1390
+ if (value.entries.length > MAX_SYNC_SESSIONS) throw new SyncSessionError("manifest-invalid", "session manifest contains too many records");
1391
+ const entries = value.entries.map(validateManifestEntry);
1392
+ const keys = entries.map(syncSessionEntryKey);
1393
+ if (new Set(keys).size !== keys.length) throw new SyncSessionError("manifest-invalid", "session manifest contains duplicate records");
1394
+ const manifest = {
1395
+ protocolVersion: 1,
1396
+ instanceId: value.instanceId,
1397
+ entries,
1398
+ skipped,
1399
+ digest: value.digest
1400
+ };
1401
+ if (sessionManifestDigest(manifest) !== manifest.digest) throw new SyncSessionError("manifest-digest", "session manifest digest does not match");
1402
+ if (Buffer.byteLength(JSON.stringify(manifest), "utf8") > 107374182400) throw new SyncSessionError("bundle-too-large", "session manifest exceeds the transfer limit");
1403
+ return manifest;
1404
+ }
995
1405
  function emptyApplyResult() {
996
1406
  return {
997
1407
  created: 0,
@@ -1066,12 +1476,16 @@ function arrayBuffer(data) {
1066
1476
  if (value.toString("base64") !== data) throw new SyncSessionError("object-invalid", "session object is not canonical base64");
1067
1477
  return value;
1068
1478
  }
1069
- function instructionsForSession(store, sessionId, state, preferredWorkspaceOrigin) {
1479
+ function instructionsForSession(store, sessionId, stateIndex, preferredWorkspaceOrigin) {
1070
1480
  const snapshot = store.get(sessionId);
1071
1481
  if (!snapshot) return void 0;
1072
- const workspaceMappings = snapshot.workspaceId ? Object.values(state.workspaces).filter((mapping) => mapping.localWorkspaceId === snapshot.workspaceId) : [];
1482
+ const workspaceMappings = snapshot.workspaceId ? stateIndex.workspacesByLocalId.get(snapshot.workspaceId) ?? [] : [];
1073
1483
  const workspaceMapping = preferredWorkspaceOrigin ? workspaceMappings.find((mapping) => mapping.originInstanceId === preferredWorkspaceOrigin.originInstanceId && mapping.originWorkspaceId === preferredWorkspaceOrigin.originEntityId) ?? (workspaceMappings.length === 1 ? workspaceMappings[0] : void 0) : workspaceMappings.length === 1 ? workspaceMappings[0] : void 0;
1074
- const inheritedFromOrigin = snapshot.inheritedFromSessionId ? findSessionOriginByLocalId(state, snapshot.inheritedFromSessionId) : void 0;
1484
+ const inheritedFrom = snapshot.inheritedFromSessionId ? stateIndex.recordsByLocalId.get(snapshot.inheritedFromSessionId) : void 0;
1485
+ const inheritedFromOrigin = inheritedFrom ? {
1486
+ originInstanceId: inheritedFrom.originInstanceId,
1487
+ originEntityId: inheritedFrom.originEntityId
1488
+ } : void 0;
1075
1489
  return {
1076
1490
  text: snapshot.text,
1077
1491
  capturedAt: snapshot.capturedAt,
@@ -1100,7 +1514,11 @@ var SyncSessionService = class {
1100
1514
  journal;
1101
1515
  now;
1102
1516
  operationTail = Promise.resolve();
1103
- constructor(state, nodes, registry, bindings, getWorkspaces, getPersistence, getAttachments, getSpills, instructions, journal = new SyncSessionJournalStore(), now = Date.now) {
1517
+ exportCache = /* @__PURE__ */ new Map();
1518
+ exportCacheStore;
1519
+ manifestPayloadCache = /* @__PURE__ */ new Map();
1520
+ manifestPayloadCacheBytes = 0;
1521
+ constructor(state, nodes, registry, bindings, getWorkspaces, getPersistence, getAttachments, getSpills, instructions, journal = new SyncSessionJournalStore(), now = Date.now, home) {
1104
1522
  this.state = state;
1105
1523
  this.nodes = nodes;
1106
1524
  this.registry = registry;
@@ -1112,10 +1530,80 @@ var SyncSessionService = class {
1112
1530
  this.instructions = instructions;
1113
1531
  this.journal = journal;
1114
1532
  this.now = now;
1533
+ this.exportCacheStore = new SyncSessionExportCacheStore(home);
1534
+ for (const [key, entry] of this.exportCacheStore.entries()) if (!this.exportCache.has(key)) this.exportCache.set(key, entry);
1115
1535
  }
1116
1536
  exportBundle() {
1117
1537
  return this.serializeMutation(() => this.exportBundleNow());
1118
1538
  }
1539
+ exportManifest() {
1540
+ return this.serializeMutation(async () => {
1541
+ const payloadCache = {
1542
+ instanceId: this.state.instanceId(),
1543
+ entries: /* @__PURE__ */ new Map(),
1544
+ bytes: 0
1545
+ };
1546
+ const bundle = await this.exportBundleNow({
1547
+ manifestOnly: true,
1548
+ payloadCache
1549
+ });
1550
+ const manifest = makeSyncSessionManifest(bundle.instanceId, bundle.entries, bundle.skipped);
1551
+ this.rememberManifestPayloads(manifest.digest, payloadCache);
1552
+ return manifest;
1553
+ });
1554
+ }
1555
+ exportEntries(keys, manifestDigest, prefixes = [], options = {}) {
1556
+ return this.serializeMutation(async () => {
1557
+ if (keys.length > MAX_SYNC_SESSIONS || keys.some((key) => typeof key !== "string" || key.length > 2048) || new Set(keys).size !== keys.length) throw new SyncSessionError("manifest-invalid", "session entry selection is invalid");
1558
+ if (manifestDigest !== void 0 && !/^sha256:[a-f0-9]{64}$/.test(manifestDigest)) throw new SyncSessionError("manifest-invalid", "session manifest digest is invalid");
1559
+ const selectedKeys = new Set(keys);
1560
+ const prefixesByKey = /* @__PURE__ */ new Map();
1561
+ for (const prefix of prefixes) {
1562
+ if (!selectedKeys.has(prefix.key) || prefixesByKey.has(prefix.key) || !isNonNegativeSafeInteger(prefix.eventCount) || prefix.eventCount > MAX_SYNC_SESSIONS * 100 || !/^sha256:[a-f0-9]{64}$/.test(prefix.eventDigest) || !isNonNegativeSafeInteger(prefix.inheritedEventCount) || prefix.inheritedEventCount > prefix.eventCount) throw new SyncSessionError("manifest-invalid", "session event prefix is invalid");
1563
+ prefixesByKey.set(prefix.key, prefix);
1564
+ }
1565
+ let bundle;
1566
+ const payloadCache = manifestDigest ? this.manifestPayloadCache.get(manifestDigest) : void 0;
1567
+ if (payloadCache) {
1568
+ const cachedEntries = keys.flatMap((key) => {
1569
+ const entry = payloadCache.entries.get(key);
1570
+ return entry ? [entry] : [];
1571
+ });
1572
+ const cachedKeys = new Set(cachedEntries.map(syncSessionEntryKey));
1573
+ const missingKeys = keys.filter((key) => !cachedKeys.has(key));
1574
+ const additional = missingKeys.length > 0 ? await this.exportBundleNow({ selectedKeys: new Set(missingKeys) }) : makeSyncSessionBundle(payloadCache.instanceId, []);
1575
+ bundle = makeSyncSessionBundle(payloadCache.instanceId, [...cachedEntries, ...additional.entries], additional.skipped);
1576
+ } else bundle = await this.exportBundleNow({
1577
+ selectedKeys,
1578
+ sharedObjects: options.sharedObjects === true
1579
+ });
1580
+ const withTails = bundle.entries.map((entry) => withEventTail(entry, prefixesByKey.get(syncSessionEntryKey(entry))));
1581
+ if (bundle.objects !== void 0) return makeSyncSessionBundle(bundle.instanceId, withTails, bundle.skipped, bundle.objects);
1582
+ const inlineEntries = withTails.map((entry) => {
1583
+ if (entry.sharedObjectIds === void 0) return entry;
1584
+ const { sharedObjectIds, ...rest } = entry;
1585
+ return rest;
1586
+ });
1587
+ const sharedObjects = options.sharedObjects === true ? this.dedupeBundleObjects(inlineEntries) : void 0;
1588
+ return makeSyncSessionBundle(bundle.instanceId, inlineEntries, bundle.skipped, sharedObjects);
1589
+ });
1590
+ }
1591
+ rememberManifestPayloads(digest, payloads) {
1592
+ if (payloads.entries.size === 0 || payloads.bytes > MAX_MANIFEST_PAYLOAD_CACHE_BYTES) return;
1593
+ const previous = this.manifestPayloadCache.get(digest);
1594
+ if (previous) {
1595
+ this.manifestPayloadCacheBytes -= previous.bytes;
1596
+ this.manifestPayloadCache.delete(digest);
1597
+ }
1598
+ while (this.manifestPayloadCache.size >= MAX_MANIFEST_PAYLOAD_CACHE_COUNT || this.manifestPayloadCacheBytes + payloads.bytes > MAX_MANIFEST_PAYLOAD_CACHE_BYTES) {
1599
+ const oldest = this.manifestPayloadCache.entries().next().value;
1600
+ if (!oldest) return;
1601
+ this.manifestPayloadCache.delete(oldest[0]);
1602
+ this.manifestPayloadCacheBytes -= oldest[1].bytes;
1603
+ }
1604
+ this.manifestPayloadCache.set(digest, payloads);
1605
+ this.manifestPayloadCacheBytes += payloads.bytes;
1606
+ }
1119
1607
  applyBundle(value, sourceName, peerId = "p-000000000000000000000000", options = {}) {
1120
1608
  return this.serializeMutation(() => this.applyBundleNow(value, sourceName, peerId, options));
1121
1609
  }
@@ -1170,13 +1658,51 @@ var SyncSessionService = class {
1170
1658
  restoreArchive(branchId) {
1171
1659
  return this.serializeMutation(() => this.restoreArchiveNow(branchId));
1172
1660
  }
1173
- async exportBundleNow() {
1661
+ async reuseCachedManifestEntry(persistence, workspace, descriptor, workspacePosition, sessionId, firstSnapshot, pinned, current, stateIndex) {
1662
+ if (!current || current.deleted || !current.workspaceOrigin) return void 0;
1663
+ const cache = this.exportCache.get(sessionExportCacheKey(String(sessionId)));
1664
+ if (!cache || cache.metadata.contentDigest !== current.contentDigest || cache.metadata.originInstanceId !== current.originInstanceId || cache.metadata.originEntityId !== current.originEntityId || current.workspaceOrigin.originInstanceId !== descriptor.originInstanceId || current.workspaceOrigin.originEntityId !== descriptor.originWorkspaceId || current.stateDigest !== sessionStateDigest(descriptor, workspacePosition, false, pinned)) return void 0;
1665
+ const stable = await readStableSessionMetadata(persistence, sessionId, firstSnapshot);
1666
+ if (!stable || !stable.header.cwd || posix.normalize(stable.header.cwd) !== posix.normalize(workspace.path)) return void 0;
1667
+ const parentMetadata = stable.header.parentSession ? stateIndex.recordsByLocalId.get(String(stable.header.parentSession)) : void 0;
1668
+ const parentOrigin = parentMetadata ? {
1669
+ originInstanceId: parentMetadata.originInstanceId,
1670
+ originEntityId: parentMetadata.originEntityId
1671
+ } : void 0;
1672
+ const instructionSnapshot = instructionsForSession(this.instructions, String(sessionId), stateIndex, {
1673
+ originInstanceId: descriptor.originInstanceId,
1674
+ originEntityId: descriptor.originWorkspaceId
1675
+ });
1676
+ const fingerprint = sessionExportFingerprint({
1677
+ revision: stable.snapshot.revision,
1678
+ header: stable.header,
1679
+ inheritedEventCount: stable.inheritedEventCount,
1680
+ workspace: descriptor,
1681
+ instructionSnapshot,
1682
+ parentOrigin
1683
+ });
1684
+ if (!fingerprint || fingerprint !== cache.fingerprint) return void 0;
1685
+ return {
1686
+ metadata: current,
1687
+ eventCount: cache.eventCount,
1688
+ eventDigest: cache.eventDigest,
1689
+ inheritedEventCount: cache.inheritedEventCount
1690
+ };
1691
+ }
1692
+ async exportBundleNow(options = {}) {
1693
+ const { manifestOnly = false, selectedKeys, sharedObjects: useSharedObjects = false } = options;
1174
1694
  const persistence = this.requirePersistence();
1175
1695
  const workspaceRegistry = this.getWorkspaces();
1176
1696
  if (!workspaceRegistry) throw new SyncSessionError("workspace-unavailable", "工作区注册表不可用");
1177
1697
  await persistence.flush();
1698
+ const stateIndex = createSessionStateIndex(this.state.read());
1178
1699
  const instanceId = this.state.instanceId();
1179
1700
  const bindings = this.bindings.list();
1701
+ const bindingByMirrorPath = /* @__PURE__ */ new Map();
1702
+ for (const binding of bindings) {
1703
+ const key = normalize(binding.mirrorPath);
1704
+ if (!bindingByMirrorPath.has(key)) bindingByMirrorPath.set(key, binding);
1705
+ }
1180
1706
  const workspaces = workspaceRegistry.list();
1181
1707
  const archivedIds = new Set(workspaceRegistry.archivedSessionIds.map(String));
1182
1708
  const pinnedIds = new Set((workspaceRegistry.pinnedSessionIds ?? []).map(String));
@@ -1184,10 +1710,33 @@ var SyncSessionService = class {
1184
1710
  const skipped = [];
1185
1711
  const eligibleLocalIds = /* @__PURE__ */ new Set();
1186
1712
  const workspaceById = /* @__PURE__ */ new Map();
1187
- const archivedBranchSessionIds = new Set(Object.values(this.state.read().archives).map((archive) => archive.localSessionId));
1713
+ const archivedBranchSessionIds = new Set(Object.values(stateIndex.state.archives).map((archive) => archive.localSessionId));
1714
+ const objectCache = /* @__PURE__ */ new Map();
1715
+ const descriptorCache = /* @__PURE__ */ new Map();
1716
+ const getWorkspaceDescriptor = (workspace, binding, nodeOrigin, preferredOrigin) => {
1717
+ const key = canonicalJson({
1718
+ workspace: {
1719
+ id: String(workspace.id),
1720
+ path: workspace.path,
1721
+ title: workspace.title
1722
+ },
1723
+ binding: {
1724
+ mirrorPath: normalize(binding.mirrorPath),
1725
+ nodeId: binding.nodeId,
1726
+ remotePath: posix.normalize(binding.remotePath)
1727
+ },
1728
+ nodeOrigin,
1729
+ preferredOrigin
1730
+ });
1731
+ const cached = descriptorCache.get(key);
1732
+ if (cached) return cached;
1733
+ const descriptor = this.workspaceDescriptor(workspace, binding, nodeOrigin, preferredOrigin, stateIndex);
1734
+ descriptorCache.set(key, descriptor);
1735
+ return descriptor;
1736
+ };
1188
1737
  for (const workspace of workspaces) {
1189
1738
  workspaceById.set(String(workspace.id), workspace);
1190
- const binding = bindings.find((candidate) => normalize(candidate.mirrorPath) === workspaceCandidateKey(workspace));
1739
+ const binding = bindingByMirrorPath.get(workspaceCandidateKey(workspace));
1191
1740
  if (!binding) continue;
1192
1741
  const node = this.registry.get(binding.nodeId);
1193
1742
  if (!node || !isNodeRemoteType(node.type)) continue;
@@ -1196,15 +1745,43 @@ var SyncSessionService = class {
1196
1745
  for (let position = 0; position < workspace.sessionIds.length; position += 1) {
1197
1746
  const sessionId = String(workspace.sessionIds[position]);
1198
1747
  if (archivedBranchSessionIds.has(sessionId)) continue;
1199
- const currentSession = findSessionRecordByLocalId(this.state.read(), sessionId);
1748
+ const currentSession = stateIndex.recordsByLocalId.get(sessionId);
1200
1749
  if (currentSession?.deleted) continue;
1201
- const descriptor = this.workspaceDescriptor(workspace, binding, nodeOrigin, currentSession?.workspaceOrigin);
1750
+ const localKey = currentSession ? syncSessionEntryKey({ metadata: currentSession }) : syncRecordKey("session", {
1751
+ originInstanceId: instanceId,
1752
+ originEntityId: sessionId
1753
+ });
1754
+ if (selectedKeys && !selectedKeys.has(localKey)) continue;
1755
+ const descriptor = getWorkspaceDescriptor(workspace, binding, nodeOrigin, currentSession?.workspaceOrigin);
1202
1756
  const snapshot = await persistence.stat(SessionId(sessionId));
1203
1757
  if (!snapshot) continue;
1204
1758
  if (snapshot.formatStatus === "migration-required") throw new SyncSessionError("session-format", "会话格式需要由当前 DSH 版本迁移");
1205
- const entry = await this.exportSessionEntry(persistence, workspace, descriptor, position, SessionId(sessionId), snapshot, archivedIds.has(sessionId), pinnedIds.has(sessionId));
1206
- if (entry) {
1207
- entries.push(entry);
1759
+ if (manifestOnly) {
1760
+ const cached = await this.reuseCachedManifestEntry(persistence, workspace, descriptor, position, SessionId(sessionId), snapshot, pinnedIds.has(sessionId), currentSession, stateIndex);
1761
+ if (cached) {
1762
+ entries.push(cached);
1763
+ eligibleLocalIds.add(sessionId);
1764
+ continue;
1765
+ }
1766
+ }
1767
+ const exported = await this.exportSessionEntry(persistence, workspace, descriptor, position, SessionId(sessionId), snapshot, archivedIds.has(sessionId), pinnedIds.has(sessionId), void 0, stateIndex, objectCache);
1768
+ if (exported) {
1769
+ const { entry } = exported;
1770
+ if (manifestOnly && options.payloadCache) {
1771
+ const key = syncSessionEntryKey(entry);
1772
+ const size = estimateJsonBytes(entry, MAX_MANIFEST_PAYLOAD_CACHE_BYTES - options.payloadCache.bytes);
1773
+ if (size !== void 0) {
1774
+ options.payloadCache.entries.set(key, entry);
1775
+ options.payloadCache.bytes += size;
1776
+ }
1777
+ }
1778
+ entries.push(manifestOnly ? {
1779
+ metadata: entry.metadata,
1780
+ ...entry.branchId ? { branchId: entry.branchId } : {},
1781
+ eventCount: exported.eventCount,
1782
+ eventDigest: exported.eventDigest,
1783
+ inheritedEventCount: exported.inheritedEventCount
1784
+ } : entry);
1208
1785
  eligibleLocalIds.add(sessionId);
1209
1786
  } else skipped.push({
1210
1787
  sessionId,
@@ -1212,11 +1789,15 @@ var SyncSessionService = class {
1212
1789
  });
1213
1790
  }
1214
1791
  }
1215
- const initial = this.state.read();
1792
+ const initial = stateIndex.state;
1216
1793
  for (const metadata of Object.values(initial.records)) {
1217
1794
  if (metadata.kind !== "session" || archivedBranchSessionIds.has(metadata.localEntityId)) continue;
1795
+ if (selectedKeys && !selectedKeys.has(syncSessionEntryKey({ metadata }))) continue;
1218
1796
  if (metadata.deleted) {
1219
1797
  entries.push({ metadata });
1798
+ const tombstoneKey = sessionExportCacheKey(metadata.localEntityId);
1799
+ this.exportCache.delete(tombstoneKey);
1800
+ this.exportCacheStore.delete(tombstoneKey);
1220
1801
  continue;
1221
1802
  }
1222
1803
  if (eligibleLocalIds.has(metadata.localEntityId)) continue;
@@ -1229,46 +1810,83 @@ var SyncSessionService = class {
1229
1810
  this.state.update((state) => {
1230
1811
  state.records[sessionOriginKey(metadata)] = tombstone;
1231
1812
  });
1813
+ indexSessionRecord(stateIndex, tombstone);
1814
+ const tombstoneKey = sessionExportCacheKey(metadata.localEntityId);
1815
+ this.exportCache.delete(tombstoneKey);
1816
+ this.exportCacheStore.delete(tombstoneKey);
1232
1817
  entries.push({ metadata: tombstone });
1233
1818
  }
1234
- for (const archive of this.listArchives()) {
1819
+ for (const archive of Object.values(stateIndex.state.archives)) {
1820
+ if (selectedKeys && !selectedKeys.has(archive.branchId)) continue;
1235
1821
  const workspace = archive.workspaceId ? workspaceById.get(archive.workspaceId) : void 0;
1236
1822
  if (!workspace) continue;
1237
- const binding = bindings.find((candidate) => normalize(candidate.mirrorPath) === workspaceCandidateKey(workspace));
1823
+ const binding = bindingByMirrorPath.get(workspaceCandidateKey(workspace));
1238
1824
  const node = binding ? this.registry.get(binding.nodeId) : void 0;
1239
1825
  if (!binding || !node || !isNodeRemoteType(node.type)) continue;
1240
1826
  const nodeOrigin = this.nodes.originForLocalNode(node.id);
1241
1827
  if (!nodeOrigin) continue;
1242
- const archivedMetadata = Object.values(this.state.read().archives).find((item) => item.localSessionId === archive.localSessionId);
1243
- const descriptor = this.workspaceDescriptor(workspace, binding, nodeOrigin, archivedMetadata?.workspaceOrigin);
1828
+ const descriptor = getWorkspaceDescriptor(workspace, binding, nodeOrigin, stateIndex.archivesByLocalSessionId.get(archive.localSessionId)?.workspaceOrigin);
1244
1829
  const snapshot = await persistence.stat(SessionId(archive.localSessionId));
1245
1830
  if (!snapshot) continue;
1246
- const entry = await this.exportSessionEntry(persistence, workspace, descriptor, archive.workspacePosition, SessionId(archive.localSessionId), snapshot, true, false, archive);
1247
- if (entry) entries.push(entry);
1248
- else skipped.push({
1831
+ const exported = await this.exportSessionEntry(persistence, workspace, descriptor, archive.workspacePosition, SessionId(archive.localSessionId), snapshot, true, false, archive, stateIndex, objectCache);
1832
+ if (exported) {
1833
+ const { entry } = exported;
1834
+ if (manifestOnly && options.payloadCache) {
1835
+ const key = syncSessionEntryKey(entry);
1836
+ const size = estimateJsonBytes(entry, MAX_MANIFEST_PAYLOAD_CACHE_BYTES - options.payloadCache.bytes);
1837
+ if (size !== void 0) {
1838
+ options.payloadCache.entries.set(key, entry);
1839
+ options.payloadCache.bytes += size;
1840
+ }
1841
+ }
1842
+ entries.push(manifestOnly ? {
1843
+ metadata: entry.metadata,
1844
+ ...entry.branchId ? { branchId: entry.branchId } : {},
1845
+ eventCount: exported.eventCount,
1846
+ eventDigest: exported.eventDigest,
1847
+ inheritedEventCount: exported.inheritedEventCount
1848
+ } : entry);
1849
+ } else skipped.push({
1249
1850
  sessionId: archive.localSessionId,
1250
1851
  reason: "archive-workspace-cwd-mismatch"
1251
1852
  });
1252
1853
  }
1253
- return makeSyncSessionBundle(instanceId, entries, skipped);
1854
+ const bundleSharedObjects = useSharedObjects && entries.some((entry) => (entry.objects?.length ?? 0) > 0) ? this.dedupeBundleObjects(entries) : void 0;
1855
+ this.exportCacheStore.flush();
1856
+ return makeSyncSessionBundle(instanceId, entries, skipped, bundleSharedObjects);
1857
+ }
1858
+ dedupeBundleObjects(entries) {
1859
+ const shared = /* @__PURE__ */ new Map();
1860
+ for (const entry of entries) for (const object of entry.objects ?? []) {
1861
+ const current = shared.get(object.id);
1862
+ if (current === void 0) shared.set(object.id, object);
1863
+ else if (current.digest !== object.digest || current.bytes !== object.bytes) throw new SyncSessionError("object-invalid", "the same object id carries different payloads");
1864
+ }
1865
+ return [...shared.values()];
1254
1866
  }
1255
1867
  async applyBundleNow(value, sourceName, peerId, options, recoveryBatch) {
1256
- const bundle = validateSyncSessionBundle(value);
1257
- const pendingBatch = recoveryBatch ?? this.journal.findBatch(peerId, bundle.digest);
1868
+ const transferredBundle = validateSyncSessionBundle(value);
1869
+ const importedVia = recoveryBatch?.importedVia ?? options.importedVia ?? "peer";
1870
+ if (importedVia !== "peer" && transferredBundle.entries.some((entry) => entry.eventDelta)) throw new SyncSessionError("manifest-invalid", "event tail deltas are only accepted from sync peers");
1871
+ let pendingBatch = recoveryBatch ?? this.journal.findBatch(peerId, transferredBundle.digest);
1872
+ if (pendingBatch && !transferredBundle.entries.some((entry) => entry.eventDelta) && (pendingBatch.bundleDigest !== transferredBundle.digest || pendingBatch.peerId !== peerId)) throw new SyncSessionError("journal-bundle-mismatch", "会话 journal batch 与 bundle 不匹配");
1873
+ if (!pendingBatch) await this.exportBundleNow({ manifestOnly: true });
1874
+ const bundle = await this.expandEventDeltas(transferredBundle);
1875
+ pendingBatch ??= recoveryBatch ?? this.journal.findBatch(peerId, bundle.digest);
1258
1876
  if (pendingBatch && (pendingBatch.bundleDigest !== bundle.digest || pendingBatch.peerId !== peerId)) throw new SyncSessionError("journal-bundle-mismatch", "会话 journal batch 与 bundle 不匹配");
1259
- const importedVia = pendingBatch?.importedVia ?? options.importedVia ?? "peer";
1877
+ const resolvedImportedVia = pendingBatch?.importedVia ?? importedVia;
1260
1878
  const allowSameInstance = pendingBatch?.allowSameInstance ?? options.allowSameInstance ?? false;
1261
1879
  const localInstanceId = this.state.instanceId();
1262
1880
  if (bundle.instanceId === localInstanceId && !allowSameInstance) throw new SyncSessionError("same-instance", "cannot import sessions from this instance");
1263
- if (!pendingBatch) await this.exportBundleNow();
1264
1881
  this.preflightBundle(bundle, {
1265
- importedVia,
1882
+ importedVia: resolvedImportedVia,
1266
1883
  allowSameInstance
1267
1884
  });
1268
1885
  const safeSourceName = (pendingBatch?.sourceName ?? sourceName).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 128) || "sync peer";
1269
1886
  const recordKeys = bundle.entries.map((entry) => entry.branchId ?? sessionOriginKey(entry.metadata));
1270
- const batchId = pendingBatch?.batchId ?? this.journal.beginWithBundle(peerId, safeSourceName, bundle.digest, recordKeys, bundle, new Date(this.now()).toISOString(), importedVia, allowSameInstance);
1887
+ const batchId = pendingBatch?.batchId ?? this.journal.beginWithBundle(peerId, safeSourceName, bundle.digest, recordKeys, bundle, new Date(this.now()).toISOString(), resolvedImportedVia, allowSameInstance);
1271
1888
  const result = emptyApplyResult();
1889
+ const attachmentImportCache = /* @__PURE__ */ new Map();
1272
1890
  result.skipped += bundle.skipped.length;
1273
1891
  result.unmapped += bundle.skipped.length;
1274
1892
  const workspaceOrders = /* @__PURE__ */ new Map();
@@ -1364,7 +1982,7 @@ var SyncSessionService = class {
1364
1982
  continue;
1365
1983
  }
1366
1984
  if (!current.deleted && entry.metadata.contentDigest === current.contentDigest && await this.requirePersistence().stat(SessionId(current.localEntityId))) {
1367
- await this.applyMembershipOnly(entry, current, safeSourceName, entryWorkspace, importedVia);
1985
+ await this.applyMembershipOnly(entry, current, safeSourceName, entryWorkspace, resolvedImportedVia);
1368
1986
  result.updated += 1;
1369
1987
  queueWorkspaceOrder(entryWorkspace.id, SessionId(current.localEntityId), entry.workspacePosition ?? 0);
1370
1988
  continue;
@@ -1374,7 +1992,7 @@ var SyncSessionService = class {
1374
1992
  let prepared;
1375
1993
  if (preparedValue === void 0) {
1376
1994
  try {
1377
- prepared = await this.prepareEntry(entry, safeSourceName, plannedSessionIds, entryWorkspace, importedVia);
1995
+ prepared = await this.prepareEntry(entry, safeSourceName, plannedSessionIds, entryWorkspace, resolvedImportedVia, attachmentImportCache);
1378
1996
  } catch (error) {
1379
1997
  if (error instanceof SyncSessionError && error.code === "workspace-unmapped") {
1380
1998
  result.skipped += 1;
@@ -1399,13 +2017,42 @@ var SyncSessionService = class {
1399
2017
  }
1400
2018
  await this.orderWorkspaceSessions(workspaceOrders);
1401
2019
  this.journal.complete(batchId);
2020
+ this.exportCacheStore.flush();
1402
2021
  return result;
1403
2022
  } catch (error) {
1404
2023
  if (error instanceof SyncSessionError) throw error;
1405
2024
  throw new SyncSessionError("session-apply-failed", "会话同步应用失败", error);
1406
2025
  }
1407
2026
  }
1408
- async prepareEntry(entry, sourceName, plannedSessionIds, workspace, importedVia) {
2027
+ async expandEventDeltas(bundle) {
2028
+ if (!bundle.entries.some((entry) => entry.eventDelta)) return bundle;
2029
+ const persistence = this.requirePersistence();
2030
+ const state = this.state.read();
2031
+ const entries = [];
2032
+ for (const entry of bundle.entries) {
2033
+ const delta = entry.eventDelta;
2034
+ if (!delta) {
2035
+ entries.push(entry);
2036
+ continue;
2037
+ }
2038
+ const current = state.records[sessionOriginKey(entry.metadata)];
2039
+ if (!current || current.deleted) throw new SyncSessionError("session-delta-prefix", "event delta base session is missing");
2040
+ const snapshot = await persistence.stat(SessionId(current.localEntityId));
2041
+ if (!snapshot) throw new SyncSessionError("session-delta-prefix", "event delta base session is missing");
2042
+ const stable = await this.readStableSession(persistence, SessionId(current.localEntityId), snapshot);
2043
+ if (stable.events.length !== delta.baseEventCount || stable.inheritedEventCount !== delta.baseInheritedEventCount || sessionEventListDigest(stable.events) !== delta.baseEventDigest) throw new SyncSessionError("session-delta-prefix", "event delta base prefix no longer matches");
2044
+ const events = [...stable.events, ...entry.events ?? []];
2045
+ if (events.length !== delta.fullEventCount || sessionEventListDigest(events) !== delta.fullEventDigest) throw new SyncSessionError("manifest-invalid", "expanded event delta digest does not match");
2046
+ const fullEntry = {
2047
+ ...entry,
2048
+ events
2049
+ };
2050
+ delete fullEntry.eventDelta;
2051
+ entries.push(fullEntry);
2052
+ }
2053
+ return validateSyncSessionBundle(makeSyncSessionBundle(bundle.instanceId, entries, bundle.skipped));
2054
+ }
2055
+ async prepareEntry(entry, sourceName, plannedSessionIds, workspace, importedVia, attachmentImportCache) {
1409
2056
  if (!entry.header || !entry.events || !entry.workspace || !entry.objects) throw new SyncSessionError("manifest-invalid", "active session entry is incomplete");
1410
2057
  const sessionId = entry.branchId ? archivedSessionId(entry.branchId) : importedSessionId(entry.metadata);
1411
2058
  const sourceHeader = entry.header;
@@ -1430,7 +2077,7 @@ var SyncSessionService = class {
1430
2077
  await handle.close();
1431
2078
  }
1432
2079
  }
1433
- const objectMap = await this.importObjects(entry.objects, sessionId);
2080
+ const objectMap = await this.importObjects(entry.objects, sessionId, attachmentImportCache);
1434
2081
  const validatedEvents = validateStoredEvents(header, this.rewriteEvents(entry.events, entry.objects, objectMap, sessionId, String(entry.header.id)));
1435
2082
  assertContiguous(sessionId, validatedEvents, 0);
1436
2083
  const instructionWorkspaceId = entry.instructionSnapshot?.workspaceOrigin ? this.localWorkspaceIdForOrigin(entry.instructionSnapshot.workspaceOrigin, entry.instructionSnapshot.workspaceRemotePath) : void 0;
@@ -1516,7 +2163,7 @@ var SyncSessionService = class {
1516
2163
  if (!committedWorkspace) throw new SyncSessionError("workspace-unmapped", "目标实例没有对应的可用远程节点");
1517
2164
  return committedWorkspace;
1518
2165
  }
1519
- async importObjects(objects, sessionId) {
2166
+ async importObjects(objects, sessionId, attachmentImportCache) {
1520
2167
  const attachments = objects.some((object) => object.kind === "image" || object.kind === "file") ? this.getAttachments() : void 0;
1521
2168
  const spills = objects.some((object) => object.kind === "spill" && !object.unavailable) ? this.getSpills() : void 0;
1522
2169
  if (objects.some((object) => object.kind === "image" || object.kind === "file") && !attachments) throw new SyncSessionError("attachments-unavailable", "目标实例的附件存储服务不可用");
@@ -1524,6 +2171,13 @@ var SyncSessionService = class {
1524
2171
  const output = /* @__PURE__ */ new Map();
1525
2172
  for (const object of objects) {
1526
2173
  if (object.kind === "spill" && object.unavailable) continue;
2174
+ if (object.kind !== "spill") {
2175
+ const cached = attachmentImportCache?.get(object.id);
2176
+ if (cached) {
2177
+ output.set(object.id, cached);
2178
+ continue;
2179
+ }
2180
+ }
1527
2181
  const data = arrayBuffer(object.data);
1528
2182
  if (data.byteLength !== object.bytes || sha256(data) !== object.digest) throw new SyncSessionError("object-integrity", "session object failed digest validation");
1529
2183
  if (object.kind === "image") {
@@ -1533,12 +2187,14 @@ var SyncSessionService = class {
1533
2187
  ...object.ref.name === void 0 ? {} : { name: object.ref.name }
1534
2188
  });
1535
2189
  output.set(object.id, ref);
2190
+ attachmentImportCache?.set(object.id, ref);
1536
2191
  } else if (object.kind === "file") {
1537
2192
  const ref = await attachments.saveFileStream({
1538
2193
  data: oneChunk(data),
1539
2194
  name: object.ref.name
1540
2195
  });
1541
2196
  output.set(object.id, ref);
2197
+ attachmentImportCache?.set(object.id, ref);
1542
2198
  } else {
1543
2199
  const ref = await saveSpillTransfer(spills, {
1544
2200
  owner: { sessionId },
@@ -1930,10 +2586,11 @@ var SyncSessionService = class {
1930
2586
  }
1931
2587
  return existed !== void 0;
1932
2588
  }
1933
- workspaceDescriptor(workspace, binding, nodeOrigin, preferredOrigin) {
1934
- const state = this.state.read();
1935
- const matchingMappings = Object.values(state.workspaces).filter((mapping) => mapping.localWorkspaceId === workspace.id && mapping.localNodeId === binding.nodeId && posix.normalize(mapping.remotePath) === posix.normalize(binding.remotePath));
1936
- const prior = (preferredOrigin ? Object.values(state.workspaces).find((mapping) => mapping.originInstanceId === preferredOrigin.originInstanceId && mapping.originWorkspaceId === preferredOrigin.originEntityId && mapping.localWorkspaceId === workspace.id && mapping.localNodeId === binding.nodeId) : void 0) ?? (matchingMappings.length === 1 ? matchingMappings[0] : void 0);
2589
+ workspaceDescriptor(workspace, binding, nodeOrigin, preferredOrigin, stateIndex = createSessionStateIndex(this.state.read())) {
2590
+ const state = stateIndex.state;
2591
+ const workspaceMappings = stateIndex.workspacesByLocalId.get(String(workspace.id)) ?? [];
2592
+ const matchingMappings = workspaceMappings.filter((mapping) => mapping.localNodeId === binding.nodeId && posix.normalize(mapping.remotePath) === posix.normalize(binding.remotePath));
2593
+ const prior = (preferredOrigin ? workspaceMappings.find((mapping) => mapping.originInstanceId === preferredOrigin.originInstanceId && mapping.originWorkspaceId === preferredOrigin.originEntityId && mapping.localNodeId === binding.nodeId) : void 0) ?? (matchingMappings.length === 1 ? matchingMappings[0] : void 0);
1937
2594
  const originInstanceId = preferredOrigin?.originInstanceId ?? prior?.originInstanceId ?? this.state.instanceId();
1938
2595
  const originWorkspaceId = preferredOrigin?.originEntityId ?? prior?.originWorkspaceId ?? workspace.id;
1939
2596
  const base = {
@@ -1948,35 +2605,52 @@ var SyncSessionService = class {
1948
2605
  const current = state.workspaces[key];
1949
2606
  const revision = current && current.contentDigest === digest ? current.revision : this.state.nextRevision(this.now());
1950
2607
  const mapping = {
1951
- ...base,
2608
+ originInstanceId,
2609
+ originWorkspaceId,
1952
2610
  localWorkspaceId: workspace.id,
1953
2611
  localPath: workspace.path,
1954
2612
  localNodeId: binding.nodeId,
2613
+ remotePath: base.remotePath,
2614
+ title: workspace.title,
1955
2615
  revision,
1956
2616
  contentDigest: digest
1957
2617
  };
1958
- this.state.update((draft) => {
1959
- draft.workspaces[key] = mapping;
1960
- });
2618
+ if (!current || canonicalJson(current) !== canonicalJson(mapping)) {
2619
+ this.state.update((draft) => {
2620
+ draft.workspaces[key] = mapping;
2621
+ });
2622
+ indexWorkspaceMapping(stateIndex, key, mapping);
2623
+ }
1961
2624
  return {
1962
2625
  ...base,
1963
2626
  revision,
1964
2627
  contentDigest: digest
1965
2628
  };
1966
2629
  }
1967
- async exportSessionEntry(persistence, workspace, descriptor, workspacePosition, sessionId, firstSnapshot, archived, pinned, archive) {
1968
- const { header, inheritedEventCount, events } = await this.readStableSession(persistence, sessionId, firstSnapshot);
1969
- if (!header.cwd || posix.normalize(header.cwd) !== posix.normalize(workspace.path)) return void 0;
2630
+ async exportSessionEntry(persistence, workspace, descriptor, workspacePosition, sessionId, firstSnapshot, archived, pinned, archive, stateIndex, objectCache) {
2631
+ const { header, inheritedEventCount, events, revision: sourceRevision } = await this.readStableSession(persistence, sessionId, firstSnapshot);
1970
2632
  const localSessionId = String(sessionId);
1971
- const state = this.state.read();
1972
- const parentOrigin = header.parentSession ? findSessionOriginByLocalId(state, String(header.parentSession)) : void 0;
1973
- const syncInstructions = instructionsForSession(this.instructions, String(sessionId), state, {
2633
+ const cacheKey = sessionExportCacheKey(localSessionId);
2634
+ if (!header.cwd || posix.normalize(header.cwd) !== posix.normalize(workspace.path)) {
2635
+ this.exportCache.delete(cacheKey);
2636
+ this.exportCacheStore.delete(cacheKey);
2637
+ return;
2638
+ }
2639
+ const index = stateIndex ?? createSessionStateIndex(this.state.read());
2640
+ const state = index.state;
2641
+ const parentMetadata = header.parentSession ? index.recordsByLocalId.get(String(header.parentSession)) : void 0;
2642
+ const parentOrigin = parentMetadata ? {
2643
+ originInstanceId: parentMetadata.originInstanceId,
2644
+ originEntityId: parentMetadata.originEntityId
2645
+ } : void 0;
2646
+ const syncInstructions = instructionsForSession(this.instructions, String(sessionId), index, {
1974
2647
  originInstanceId: descriptor.originInstanceId,
1975
2648
  originEntityId: descriptor.originWorkspaceId
1976
2649
  });
1977
- const objects = await this.exportObjects(events, localSessionId);
2650
+ const eventDigest = sessionEventListDigest(events);
2651
+ const objects = await this.exportObjects(events, localSessionId, objectCache);
1978
2652
  const digest = sessionEventDigest(header, inheritedEventCount, events, descriptor, syncInstructions, objects, parentOrigin);
1979
- const current = archive ? void 0 : findSessionRecordByLocalId(state, localSessionId) ?? state.records[syncRecordKey("session", {
2653
+ const current = archive ? void 0 : index.recordsByLocalId.get(localSessionId) ?? state.records[syncRecordKey("session", {
1980
2654
  originInstanceId: this.state.instanceId(),
1981
2655
  originEntityId: localSessionId
1982
2656
  })];
@@ -2015,6 +2689,7 @@ var SyncSessionService = class {
2015
2689
  delete draft.archives[archive.branchId];
2016
2690
  draft.archives[branchId] = updatedArchive;
2017
2691
  });
2692
+ indexArchive(index, archive.branchId, updatedArchive);
2018
2693
  outputBranchId = branchId;
2019
2694
  } else contentDigest = archive.contentDigest;
2020
2695
  } else if (!current || current.contentDigest !== digest || current.deleted || workspaceOriginChanged || !current.workspaceOrigin || sessionStateChanged) revision = this.state.nextRevision(this.now());
@@ -2030,23 +2705,60 @@ var SyncSessionService = class {
2030
2705
  ...archive ? {} : current?.importedVia ? { importedVia: current.importedVia } : {},
2031
2706
  ...archive ? {} : current?.sourceName ? { sourceName: current.sourceName } : {}
2032
2707
  };
2033
- if (!archive && (!current || current.contentDigest !== digest || current.deleted || workspaceOriginChanged || !current.workspaceOrigin || sessionStateChanged)) this.state.update((draft) => {
2034
- draft.records[sessionOriginKey(metadata)] = metadata;
2035
- });
2708
+ if (!archive && (!current || current.contentDigest !== digest || current.deleted || workspaceOriginChanged || !current.workspaceOrigin || sessionStateChanged)) {
2709
+ this.state.update((draft) => {
2710
+ draft.records[sessionOriginKey(metadata)] = metadata;
2711
+ });
2712
+ indexSessionRecord(index, metadata);
2713
+ }
2714
+ if (!archive && !objects.some((object) => object.kind === "spill")) {
2715
+ const fingerprint = sessionExportFingerprint({
2716
+ revision: sourceRevision,
2717
+ header,
2718
+ inheritedEventCount,
2719
+ workspace: descriptor,
2720
+ instructionSnapshot: syncInstructions,
2721
+ parentOrigin
2722
+ });
2723
+ if (fingerprint) {
2724
+ const entry = {
2725
+ fingerprint,
2726
+ metadata,
2727
+ eventCount: events.length,
2728
+ eventDigest,
2729
+ inheritedEventCount
2730
+ };
2731
+ this.exportCache.set(cacheKey, entry);
2732
+ this.exportCacheStore.set(cacheKey, entry);
2733
+ } else {
2734
+ this.exportCache.delete(cacheKey);
2735
+ this.exportCacheStore.delete(cacheKey);
2736
+ }
2737
+ } else {
2738
+ this.exportCache.delete(cacheKey);
2739
+ this.exportCacheStore.delete(cacheKey);
2740
+ }
2036
2741
  return {
2037
- metadata,
2038
- ...outputBranchId ? { branchId: outputBranchId } : {},
2039
- sourceLocalSessionId: localSessionId,
2040
- header,
2041
- inheritedEventCount,
2042
- ...parentOrigin ? { parentOrigin } : {},
2043
- workspace: descriptor,
2044
- workspacePosition,
2045
- archived,
2046
- pinned,
2047
- ...syncInstructions ? { instructionSnapshot: syncInstructions } : {},
2048
- events: [...events],
2049
- objects
2742
+ entry: {
2743
+ metadata,
2744
+ ...outputBranchId ? { branchId: outputBranchId } : {},
2745
+ sourceLocalSessionId: localSessionId,
2746
+ header,
2747
+ inheritedEventCount,
2748
+ ...parentOrigin ? { parentOrigin } : {},
2749
+ workspace: descriptor,
2750
+ workspacePosition,
2751
+ archived,
2752
+ pinned,
2753
+ ...syncInstructions ? { instructionSnapshot: syncInstructions } : {},
2754
+ events: [...events],
2755
+ objects,
2756
+ eventCount: events.length,
2757
+ eventDigest
2758
+ },
2759
+ eventCount: events.length,
2760
+ eventDigest,
2761
+ inheritedEventCount
2050
2762
  };
2051
2763
  }
2052
2764
  async readStableSession(persistence, sessionId, firstSnapshot) {
@@ -2069,10 +2781,11 @@ var SyncSessionService = class {
2069
2781
  assertContiguous(sessionId, validated, 0);
2070
2782
  const after = await persistence.stat(sessionId);
2071
2783
  if (!after) throw new SyncSessionError("session-missing", "会话在导出期间消失");
2072
- if (JSON.stringify(snapshot.revision) === JSON.stringify(after.revision)) return {
2784
+ if (canonicalJson(snapshot.revision) === canonicalJson(after.revision) && canonicalJson(snapshot.header) === canonicalJson(after.header) && canonicalJson(handle.header) === canonicalJson(after.header)) return {
2073
2785
  header: handle.header,
2074
2786
  inheritedEventCount: Number(handle.inheritedEventCount),
2075
- events: [...validated]
2787
+ events: [...validated],
2788
+ revision: after.revision
2076
2789
  };
2077
2790
  snapshot = after;
2078
2791
  } finally {
@@ -2081,7 +2794,7 @@ var SyncSessionService = class {
2081
2794
  }
2082
2795
  throw new SyncSessionError("session-changing", "会话在导出期间持续变化,请稍后重试");
2083
2796
  }
2084
- async exportObjects(events, sessionId) {
2797
+ async exportObjects(events, sessionId, objectCache) {
2085
2798
  const attachments = attachmentReferences(events);
2086
2799
  const spills = spillReferences(events);
2087
2800
  const structuredSpills = new Set(spills.map(spillKey));
@@ -2090,27 +2803,43 @@ var SyncSessionService = class {
2090
2803
  const attachmentStore = attachments.images.length + attachments.files.length > 0 ? this.getAttachments() : void 0;
2091
2804
  if (attachments.images.length + attachments.files.length > 0 && !attachmentStore) throw new SyncSessionError("attachments-unavailable", "附件存储服务不可用");
2092
2805
  for (const ref of attachments.images) {
2806
+ const key = attachmentKey("image", ref);
2807
+ const cached = objectCache?.get(key)?.object;
2808
+ if (cached?.kind === "image" && canonicalJson(cached.ref) === canonicalJson(ref)) {
2809
+ payloads.set(key, cached);
2810
+ continue;
2811
+ }
2093
2812
  const stored = await attachmentStore.readImage(ref);
2094
2813
  const digest = sha256(stored.data);
2095
- payloads.set(attachmentKey("image", ref), {
2096
- id: attachmentKey("image", ref),
2814
+ const object = {
2815
+ id: key,
2097
2816
  kind: "image",
2098
2817
  ref: stored.ref,
2099
2818
  bytes: stored.data.byteLength,
2100
2819
  digest,
2101
2820
  data: Buffer.from(stored.data).toString("base64")
2102
- });
2821
+ };
2822
+ payloads.set(key, object);
2823
+ objectCache?.set(key, { object });
2103
2824
  }
2104
2825
  for (const ref of attachments.files) {
2826
+ const key = attachmentKey("file", ref);
2827
+ const cached = objectCache?.get(key)?.object;
2828
+ if (cached?.kind === "file" && canonicalJson(cached.ref) === canonicalJson(ref)) {
2829
+ payloads.set(key, cached);
2830
+ continue;
2831
+ }
2105
2832
  const data = await collectBytes(attachmentStore.readFileStream(ref));
2106
- payloads.set(attachmentKey("file", ref), {
2107
- id: attachmentKey("file", ref),
2833
+ const object = {
2834
+ id: key,
2108
2835
  kind: "file",
2109
2836
  ref,
2110
2837
  bytes: data.byteLength,
2111
2838
  digest: sha256(data),
2112
2839
  data: data.toString("base64")
2113
- });
2840
+ };
2841
+ payloads.set(key, object);
2842
+ objectCache?.set(key, { object });
2114
2843
  }
2115
2844
  if (spills.length > 0) {
2116
2845
  const store = this.getSpills();
@@ -2120,6 +2849,12 @@ var SyncSessionService = class {
2120
2849
  untransferred.push(reference);
2121
2850
  continue;
2122
2851
  }
2852
+ const key = spillKey(reference);
2853
+ const cached = objectCache?.get(key)?.object;
2854
+ if (cached?.kind === "spill" && canonicalJson(cached.reference) === canonicalJson(reference) && (reference.ref.bytes === void 0 || cached.bytes === reference.ref.bytes)) {
2855
+ payloads.set(key, cached);
2856
+ continue;
2857
+ }
2123
2858
  try {
2124
2859
  const data = await collectBytes(readSpillStream(store, reference));
2125
2860
  if (reference.ref.bytes !== void 0 && data.byteLength !== reference.ref.bytes) {
@@ -2132,15 +2867,16 @@ var SyncSessionService = class {
2132
2867
  untransferred.push(reference);
2133
2868
  continue;
2134
2869
  }
2135
- const key = spillKey(reference);
2136
- payloads.set(key, {
2870
+ const object = {
2137
2871
  id: key,
2138
2872
  kind: "spill",
2139
2873
  reference,
2140
2874
  bytes: data.byteLength,
2141
2875
  digest: sha256(data),
2142
2876
  data: data.toString("base64")
2143
- });
2877
+ };
2878
+ payloads.set(key, object);
2879
+ objectCache?.set(key, { object });
2144
2880
  } catch {
2145
2881
  untransferred.push(reference);
2146
2882
  }
@@ -2172,6 +2908,6 @@ var SyncSessionService = class {
2172
2908
  }
2173
2909
  };
2174
2910
  //#endregion
2175
- export { validateSyncSessionBundle as a, planSessionTransfers as i, SyncSessionService as n, makeSyncSessionBundle as r, SyncSessionError as t };
2911
+ export { planSessionTransfers as a, validateSyncSessionBundle as c, makeSyncSessionManifest as i, validateSyncSessionManifest as l, SyncSessionService as n, sessionEventListDigest as o, makeSyncSessionBundle as r, syncSessionEntryKey as s, SyncSessionError as t };
2176
2912
 
2177
- //# sourceMappingURL=session-sync-EdfwUON5.js.map
2913
+ //# sourceMappingURL=session-sync-C0uoizyB.js.map