@appsoftwareltd/etherpk-mcp 0.15.0 → 0.16.0

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/dist/main.js CHANGED
@@ -627,8 +627,8 @@ var ASSET_CHUNK_PLAINTEXT_BYTES = 4194304;
627
627
  function assetChunkCount(size) {
628
628
  return Math.max(1, Math.ceil(size / ASSET_CHUNK_PLAINTEXT_BYTES));
629
629
  }
630
- var DAY = 864e5;
631
- 365 * DAY, 30 * DAY, 7 * DAY;
630
+ var DAY$1 = 864e5;
631
+ 365 * DAY$1, 30 * DAY$1, 7 * DAY$1;
632
632
  /** Whether `text` is a short link a Sync Server opens: one of Google's share links. */
633
633
  function isShortMapLink(text) {
634
634
  let url;
@@ -717,7 +717,7 @@ function looksLikeAgentSetupCode(value) {
717
717
  }
718
718
  var package_default = {
719
719
  name: "@appsoftwareltd/etherpk-mcp",
720
- version: "0.15.0",
720
+ version: "0.16.0",
721
721
  license: "Elastic-2.0",
722
722
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
723
723
  type: "module",
@@ -863,7 +863,7 @@ function fromBase64Url(text) {
863
863
  * is indistinguishable from random, so the server can tell two assets in *one* graph are
864
864
  * identical and nothing more; the secret is per graph, so nothing compares across graphs.
865
865
  */
866
- var INFO = "etherpk/asset-dedup/v1";
866
+ var INFO$1 = "etherpk/asset-dedup/v1";
867
867
  /** The graph's dedup secret (32 bytes) for its newest epoch. Derive once per epoch. */
868
868
  async function deriveAssetDedupSecret(keyring) {
869
869
  const newest = keyring.epochs.at(-1);
@@ -873,7 +873,7 @@ async function deriveAssetDedupSecret(keyring) {
873
873
  name: "HKDF",
874
874
  hash: "SHA-256",
875
875
  salt: utf8(keyring.graphId),
876
- info: utf8(INFO)
876
+ info: utf8(INFO$1)
877
877
  }, ikm, 256);
878
878
  return new Uint8Array(bits);
879
879
  }
@@ -1542,6 +1542,50 @@ async function openVault(envelope, key) {
1542
1542
  throw new EnvelopeError(`unexpected vault envelope kind ${envelope[1] ?? "none"}`);
1543
1543
  }
1544
1544
  contextAad("settings-vault", "v1");
1545
+ //#endregion
1546
+ //#region ../client/src/lib/crypto/journal-doc-id.ts
1547
+ /**
1548
+ * The **journal document id** (ADR 0157): a [[Journal Entry]]'s document id, derived from its day,
1549
+ * so two devices that start the same day before they hear of each other write into one document
1550
+ * and the CRDT merges their text, instead of leaving two documents behind one name.
1551
+ *
1552
+ * secret = HKDF-SHA-256(ikm = epoch 1 key, salt = graph id, info = 'etherpk/journal-doc-id/v1')
1553
+ * id = HMAC-SHA-256(secret, 'YYYY-MM-DD'), first 128 bits, as a version 8 UUID
1554
+ *
1555
+ * The **epoch 1** key is the input, never the newest (unlike the asset dedup token): the id must
1556
+ * not change when the graph moves to a new Graph Key epoch (ADR 0127), or a day started before a
1557
+ * rotation and the same day started after it would get two documents. Every member holds every
1558
+ * epoch, so every member derives the same id. The server sees document ids already, and without
1559
+ * the key an id is indistinguishable from a random one, so it learns no day from it.
1560
+ */
1561
+ var INFO = "etherpk/journal-doc-id/v1";
1562
+ var DAY = /^\d{4}-\d{2}-\d{2}$/;
1563
+ /** The graph's journal id secret (32 bytes). It never changes, so derive it once per session. */
1564
+ async function deriveJournalIdSecret(keyring) {
1565
+ const first = keyForEpoch(keyring, 1);
1566
+ if (!first) throw new Error("deriveJournalIdSecret: keyring has no epoch 1 key");
1567
+ const ikm = await crypto.subtle.importKey("raw", first, "HKDF", false, ["deriveBits"]);
1568
+ const bits = await crypto.subtle.deriveBits({
1569
+ name: "HKDF",
1570
+ hash: "SHA-256",
1571
+ salt: utf8(keyring.graphId),
1572
+ info: utf8(INFO)
1573
+ }, ikm, 256);
1574
+ return new Uint8Array(bits);
1575
+ }
1576
+ /** The document id of the journal entry for `day` (`YYYY-MM-DD`), under the graph's secret. */
1577
+ async function journalDocId(secret, day) {
1578
+ if (!DAY.test(day)) throw new Error(`journalDocId: "${day}" is not a calendar day`);
1579
+ const key = await crypto.subtle.importKey("raw", secret, {
1580
+ name: "HMAC",
1581
+ hash: "SHA-256"
1582
+ }, false, ["sign"]);
1583
+ const bytes = new Uint8Array(await crypto.subtle.sign("HMAC", key, utf8(day))).slice(0, 16);
1584
+ bytes[6] = bytes[6] & 15 | 128;
1585
+ bytes[8] = bytes[8] & 63 | 128;
1586
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
1587
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1588
+ }
1545
1589
  var PAD_TO = 64;
1546
1590
  var LENGTH_PREFIX = 2;
1547
1591
  function graphNameAad(graphId) {
@@ -7910,6 +7954,12 @@ var REMOTE = Symbol("etherpk-remote");
7910
7954
  var CACHE_SEED = Symbol("etherpk-cache-seed");
7911
7955
  /** Structural lifecycle changes which must never be mistaken for a user's edit. */
7912
7956
  var SUPPRESSED = Symbol("etherpk-suppressed");
7957
+ /**
7958
+ * A created document's text written again as this device's own insert, so it can be sent whole
7959
+ * to a generation that never held the structs it was first written with (ADR 0157). Local, so it
7960
+ * is sent, but no undo manager tracks it.
7961
+ */
7962
+ var RECREATED = Symbol("etherpk-recreated");
7913
7963
  /** Health that says the bytes are not this document's, so neither text nor emptiness is real. */
7914
7964
  function contentBlocked(health) {
7915
7965
  return health === "key-unavailable" || health === "ciphertext-corrupt";
@@ -8002,6 +8052,14 @@ function createDocSync(deps) {
8002
8052
  let pendingSnapshot;
8003
8053
  /** The newest epoch an update needed that the keyring does not hold (ADR 0127). */
8004
8054
  let missingEpoch;
8055
+ /** Set by `claimCreation` until the relay acknowledges this device's first text. */
8056
+ let createdHere = false;
8057
+ /**
8058
+ * Set when the relay answered `stale_generation` to text this device created or resurrected:
8059
+ * the next catch-up page tells which generation the relay holds, and that text is then
8060
+ * written again and sent whole into it rather than cleared.
8061
+ */
8062
+ let recreating = false;
8005
8063
  /** Outbox operations the relay refused as sealed under an older epoch: sealed again on `keysChanged`. */
8006
8064
  const refusedAsStale = /* @__PURE__ */ new Set();
8007
8065
  /** A read-back failed: the next idle uploads a fresh snapshot whatever the tail size. */
@@ -8031,6 +8089,44 @@ function createDocSync(deps) {
8031
8089
  catchupActive = false;
8032
8090
  catchupCompletion.resolve();
8033
8091
  }
8092
+ /**
8093
+ * The relay holds this document at a generation that the text this device created was not
8094
+ * written in (ADR 0157). Move to that generation, and write the text again as one insert of
8095
+ * this device's own, so it depends on no struct the generation never held (a deterministic
8096
+ * Journal Template's among them). The next send carries the whole state: a `resurrect` into a
8097
+ * deleted generation, an append into one that another device started first.
8098
+ */
8099
+ function recreateInto(nextGeneration, state) {
8100
+ recreating = false;
8101
+ documentGeneration = nextGeneration;
8102
+ lifecycleState = state;
8103
+ lastSeq = 0;
8104
+ syncHealth = "healthy";
8105
+ const content = doc.getText("content");
8106
+ const text = content.toString();
8107
+ if (text === "") return;
8108
+ doc.transact(() => {
8109
+ content.delete(0, text.length);
8110
+ content.insert(0, text);
8111
+ }, RECREATED);
8112
+ }
8113
+ /**
8114
+ * A catch-up has reached a newer active generation than this device's copy (ADR 0157): the
8115
+ * document was deleted, and started again, while this device was away. The copy holds the
8116
+ * deleted generation's text, so it is cleared, never merged into the new one. The new
8117
+ * generation never held the structs that remain, so the next send carries the whole state.
8118
+ */
8119
+ function discardOlderGeneration() {
8120
+ const content = doc.getText("content");
8121
+ seeding = true;
8122
+ try {
8123
+ if (content.length > 0) doc.transact(() => content.delete(0, content.length), SUPPRESSED);
8124
+ } finally {
8125
+ seeding = false;
8126
+ }
8127
+ lastSyncedStateVector = Y.encodeStateVector(new Y.Doc());
8128
+ lastSyncedSnapshot = Y.snapshot(doc);
8129
+ }
8034
8130
  function failCatchup(error) {
8035
8131
  const reported = asError(error);
8036
8132
  firstCatchupPage.reject(reported);
@@ -8138,7 +8234,8 @@ function createDocSync(deps) {
8138
8234
  epochId: epoch.epochId,
8139
8235
  envelope: toBase64Url(await encrypt(plaintext)),
8140
8236
  stateVector: operationStateVector,
8141
- createdAt: now()
8237
+ createdAt: now(),
8238
+ ...createdHere ? { creates: true } : {}
8142
8239
  };
8143
8240
  const state = stateSnapshot();
8144
8241
  await persistTask(() => persist.enqueue(operation, state, { settledDirtyTokens }));
@@ -8506,6 +8603,7 @@ function createDocSync(deps) {
8506
8603
  }
8507
8604
  lastSyncedStateVector = mergeStateVectors(lastSyncedStateVector, operation.stateVector);
8508
8605
  lifecycleState = "active";
8606
+ createdHere = false;
8509
8607
  updatesSinceSnapshot += 1;
8510
8608
  bytesSinceSnapshot += fromBase64Url(operation.envelope).byteLength;
8511
8609
  scheduleAutomaticCompaction();
@@ -8529,6 +8627,21 @@ function createDocSync(deps) {
8529
8627
  try {
8530
8628
  const beforeBatch = indexMutationVersion;
8531
8629
  if (message.generation < documentGeneration) break;
8630
+ if (recreating) {
8631
+ recreateInto(message.generation, message.state);
8632
+ if (message.state === "deleted") {
8633
+ await saveCurrent({ indexContentChanged: true });
8634
+ futureUpdates.clear();
8635
+ completeCatchup();
8636
+ break;
8637
+ }
8638
+ } else if (message.state === "active" && message.generation > documentGeneration && lifecycleState === "active") discardOlderGeneration();
8639
+ if (message.state === "deleted" && message.generation === documentGeneration && (lifecycleState === "deleted" || lifecycleState === "resurrecting")) {
8640
+ lastSeq = 0;
8641
+ futureUpdates.clear();
8642
+ completeCatchup();
8643
+ break;
8644
+ }
8532
8645
  if (message.state === "deleted") {
8533
8646
  documentGeneration = message.generation;
8534
8647
  lifecycleState = "deleted";
@@ -8635,10 +8748,21 @@ function createDocSync(deps) {
8635
8748
  await transmit(durableQueue[0]);
8636
8749
  },
8637
8750
  lifecycle: () => lifecycleState,
8751
+ generation: () => documentGeneration,
8752
+ claimCreation() {
8753
+ createdHere = true;
8754
+ },
8638
8755
  health: () => syncHealth,
8639
8756
  async staleGeneration(currentGeneration) {
8640
8757
  syncHealth = "generation-stale";
8641
- for (const operation of durableQueue.splice(0)) await persist.discard(operation.outboxId);
8758
+ const discarded = durableQueue.splice(0);
8759
+ if (createdHere || discarded.some((operation) => operation.kind === "resurrect" || operation.kind === "append" && operation.creates)) {
8760
+ recreating = true;
8761
+ createdHere = true;
8762
+ lastSyncedStateVector = Y.encodeStateVector(new Y.Doc());
8763
+ lastSyncedSnapshot = void 0;
8764
+ } else if (currentGeneration > documentGeneration && lifecycleState === "active") discardOlderGeneration();
8765
+ for (const operation of discarded) await persist.discard(operation.outboxId);
8642
8766
  queueChanged();
8643
8767
  boundarySnapshots.clear();
8644
8768
  documentGeneration = currentGeneration;
@@ -9720,6 +9844,14 @@ function createGraphSync(deps) {
9720
9844
  const foregroundCatchups = [];
9721
9845
  const backgroundCatchups = [];
9722
9846
  let catchupInFlight;
9847
+ /**
9848
+ * The request the in-flight one's document asked for again while it was in flight. The relay
9849
+ * may have composed the in-flight answer before whatever prompted the second request: an ack
9850
+ * naming a sequence that another device's concurrent first write took, for one. Dropped as a
9851
+ * duplicate, that document stayed behind for good, so it runs once more if the answer leaves
9852
+ * the document with a gap.
9853
+ */
9854
+ let catchupRepeat;
9723
9855
  let resolveFirstOpen;
9724
9856
  const firstOpen = new Promise((resolve) => {
9725
9857
  resolveFirstOpen = resolve;
@@ -9812,7 +9944,10 @@ function createGraphSync(deps) {
9812
9944
  }
9813
9945
  function queueCatchup(message) {
9814
9946
  if (!open) return;
9815
- if (catchupInFlight?.docId === message.docId && catchupInFlight.generation === message.generation && catchupInFlight.afterSeq === message.afterSeq) return;
9947
+ if (catchupInFlight?.docId === message.docId && catchupInFlight.generation === message.generation && catchupInFlight.afterSeq === message.afterSeq) {
9948
+ catchupRepeat = message;
9949
+ return;
9950
+ }
9816
9951
  const removeQueued = (queue) => {
9817
9952
  const index = queue.findIndex((candidate) => candidate.docId === message.docId);
9818
9953
  if (index >= 0) queue.splice(index, 1);
@@ -9826,6 +9961,9 @@ function createGraphSync(deps) {
9826
9961
  if (!requestId || catchupInFlight?.requestId !== requestId) return;
9827
9962
  const completedDocId = catchupInFlight.docId;
9828
9963
  catchupInFlight = void 0;
9964
+ const repeat = catchupRepeat?.docId === completedDocId ? catchupRepeat : void 0;
9965
+ catchupRepeat = void 0;
9966
+ if (repeat && engines.get(completedDocId)?.health() === "sequence-gap") queueCatchup(repeat);
9829
9967
  pumpCatchups();
9830
9968
  retireEngineIfIdle(completedDocId);
9831
9969
  }
@@ -10125,7 +10263,10 @@ function createGraphSync(deps) {
10125
10263
  return;
10126
10264
  }
10127
10265
  if (message.code === "stale_generation" && message.docId && message.currentGeneration !== void 0) {
10128
- if (catchupInFlight?.docId === message.docId) catchupInFlight = void 0;
10266
+ if (catchupInFlight?.docId === message.docId) {
10267
+ catchupInFlight = void 0;
10268
+ catchupRepeat = void 0;
10269
+ }
10129
10270
  operationAnswered(message.docId);
10130
10271
  engine(message.docId).staleGeneration(message.currentGeneration);
10131
10272
  pumpCatchups();
@@ -10183,6 +10324,7 @@ function createGraphSync(deps) {
10183
10324
  operationsInFlight.clear();
10184
10325
  operationsWaiting.clear();
10185
10326
  catchupInFlight = void 0;
10327
+ catchupRepeat = void 0;
10186
10328
  foregroundCatchups.length = 0;
10187
10329
  backgroundCatchups.length = 0;
10188
10330
  for (const pending of watermarkRequests.values()) {
@@ -10311,6 +10453,7 @@ function createGraphSync(deps) {
10311
10453
  });
10312
10454
  }
10313
10455
  connect();
10456
+ let journalIdSecret;
10314
10457
  return {
10315
10458
  rootDoc: root.doc,
10316
10459
  docSync: (docId) => engine(docId),
@@ -10367,6 +10510,10 @@ function createGraphSync(deps) {
10367
10510
  compactDoc: (docId) => deps.cache.docCache(docId).compact(),
10368
10511
  uploadSnapshot: (docId) => engine(docId).compact(),
10369
10512
  registry: () => registryMap,
10513
+ journalDocId(date) {
10514
+ journalIdSecret ??= deriveJournalIdSecret(keyring());
10515
+ return journalIdSecret.then((secret) => journalDocId(secret, date));
10516
+ },
10370
10517
  getMeta() {
10371
10518
  const name = metaMap.get("name");
10372
10519
  const settings = metaMap.get("settings");
@@ -10623,6 +10770,7 @@ function createGraphSync(deps) {
10623
10770
  foregroundCatchups.length = 0;
10624
10771
  backgroundCatchups.length = 0;
10625
10772
  catchupInFlight = void 0;
10773
+ catchupRepeat = void 0;
10626
10774
  for (const pending of watermarkRequests.values()) {
10627
10775
  clearTimeout(pending.timer);
10628
10776
  pending.reject(/* @__PURE__ */ new Error("the graph sync session was disposed"));
@@ -14029,7 +14177,7 @@ async function publishedFence(asked, info, concept, pictures, warnings) {
14029
14177
  function assetHrefOf(name) {
14030
14178
  return `assets/${encodeURIComponent(name)}`;
14031
14179
  }
14032
- function bodyOf$1(doc) {
14180
+ function bodyOf$2(doc) {
14033
14181
  return parseFrontmatter(doc.text).body;
14034
14182
  }
14035
14183
  /** Every `http(s)` resource a piece of HTML or CSS pulls in, for the report. */
@@ -14216,7 +14364,7 @@ async function publishPublication(source, publication, env, options = {}) {
14216
14364
  });
14217
14365
  const bodies = [...included, ...includeDocs.values()].map((d) => ({
14218
14366
  doc: d,
14219
- body: bodyOf$1(d)
14367
+ body: bodyOf$2(d)
14220
14368
  }));
14221
14369
  for (const { doc, body } of bodies) for (const fence of probe.fences(body)) {
14222
14370
  const fenceKey = `${fence.lang}\n${fence.code}`;
@@ -14329,7 +14477,7 @@ async function publishPublication(source, publication, env, options = {}) {
14329
14477
  progress("rendering", done++, included.length);
14330
14478
  const slug = slugs.get(conceptKey$1(doc.concept));
14331
14479
  scopeDiagrams(`mermaid_${slug}`);
14332
- const rendered = renderer.render(bodyOf$1(doc));
14480
+ const rendered = renderer.render(bodyOf$2(doc));
14333
14481
  const isHome = doc === homeDoc;
14334
14482
  const page = {
14335
14483
  doc,
@@ -14346,7 +14494,7 @@ async function publishPublication(source, publication, env, options = {}) {
14346
14494
  if (dated.date !== void 0) page.date = dated.date;
14347
14495
  take(dated.issues);
14348
14496
  }
14349
- for (const link of linksOf(bodyOf$1(doc), resolver, doc)) report.missingLinks.push(link);
14497
+ for (const link of linksOf(bodyOf$2(doc), resolver, doc)) report.missingLinks.push(link);
14350
14498
  pages.push(page);
14351
14499
  report.included.push({
14352
14500
  concept: doc.concept,
@@ -14387,7 +14535,7 @@ async function publishPublication(source, publication, env, options = {}) {
14387
14535
  });
14388
14536
  continue;
14389
14537
  }
14390
- const body = bodyOf$1(doc);
14538
+ const body = bodyOf$2(doc);
14391
14539
  if (cssSlots.has(slot)) {
14392
14540
  const fence = renderer.fences(body).find((f) => f.lang === "css");
14393
14541
  if (!fence) {
@@ -14558,7 +14706,7 @@ async function publishPublication(source, publication, env, options = {}) {
14558
14706
  })));
14559
14707
  const wanted = /* @__PURE__ */ new Map();
14560
14708
  for (const page of pages) for (const name of page.rendered.assets) if (!wanted.has(name)) wanted.set(name, page.doc.concept);
14561
- for (const [, doc] of includeDocs) for (const name of renderer.render(bodyOf$1(doc)).assets) if (!wanted.has(name)) wanted.set(name, doc.concept);
14709
+ for (const [, doc] of includeDocs) for (const name of renderer.render(bodyOf$2(doc)).assets) if (!wanted.has(name)) wanted.set(name, doc.concept);
14562
14710
  let assetsDone = 0;
14563
14711
  for (const [name, from] of wanted) {
14564
14712
  progress("assets", assetsDone++, wanted.size);
@@ -23854,7 +24002,7 @@ function describeFilesystemSaveFailure(error, concept) {
23854
24002
  //#endregion
23855
24003
  //#region src/headless-documents.ts
23856
24004
  /** The document's text after its frontmatter block, and where that block ends. */
23857
- function bodyOf(text) {
24005
+ function bodyOf$1(text) {
23858
24006
  const head = frontmatterSpan(text)?.end ?? 0;
23859
24007
  return {
23860
24008
  head,
@@ -23869,16 +24017,16 @@ function bodyOf(text) {
23869
24017
  function bodyView(handle) {
23870
24018
  return {
23871
24019
  id: handle.id,
23872
- getText: () => bodyOf(handle.getText()).body,
24020
+ getText: () => bodyOf$1(handle.getText()).body,
23873
24021
  applyChange(change, origin) {
23874
- const { head } = bodyOf(handle.getText());
24022
+ const { head } = bodyOf$1(handle.getText());
23875
24023
  handle.applyChange({
23876
24024
  from: change.from + head,
23877
24025
  to: change.to + head,
23878
24026
  insert: change.insert
23879
24027
  }, origin);
23880
24028
  },
23881
- subscribe: (listener) => handle.subscribe((text) => listener(bodyOf(text).body))
24029
+ subscribe: (listener) => handle.subscribe((text) => listener(bodyOf$1(text).body))
23882
24030
  };
23883
24031
  }
23884
24032
  //#endregion
@@ -24004,6 +24152,34 @@ function createBreather(now = () => performance.now()) {
24004
24152
  };
24005
24153
  }
24006
24154
  //#endregion
24155
+ //#region ../client/src/lib/storage/server/duplicate-names.ts
24156
+ /** A document's text below its [[Frontmatter]] block, if it has one. */
24157
+ function bodyOf(text) {
24158
+ return text.slice(frontmatterSpan(text)?.end ?? 0);
24159
+ }
24160
+ /**
24161
+ * The text a Merge leaves in the first document: its own text whole, then each other document's
24162
+ * text below its block, after a blank line, in the order given. Every other document's identity is
24163
+ * already the first one's, and its aliases join in the registry, so its block adds nothing.
24164
+ *
24165
+ * Always the first text followed by an addition, so the store writes it as an append: an edit made
24166
+ * meanwhile on another device anywhere in the first document is kept.
24167
+ */
24168
+ function mergedText(texts) {
24169
+ let merged = texts[0] ?? "";
24170
+ for (const other of texts.slice(1)) {
24171
+ const body = bodyOf(other).replace(/^\s+/, "");
24172
+ if (body === "") continue;
24173
+ if (merged.trim() === "") {
24174
+ merged += body;
24175
+ continue;
24176
+ }
24177
+ const separator = merged.endsWith("\n\n") ? "" : merged.endsWith("\n") ? "\n" : "\n\n";
24178
+ merged += separator + body;
24179
+ }
24180
+ return merged;
24181
+ }
24182
+ //#endregion
24007
24183
  //#region ../client/src/lib/storage/server/server-document-store.ts
24008
24184
  /**
24009
24185
  * ServerDocumentStore (plan Phase 3 Task 6): the DocumentStore seam over the E2EE sync
@@ -24021,6 +24197,12 @@ function createServerDocumentStore(graph, options) {
24021
24197
  const ENGINE_BATCH = 100;
24022
24198
  /** Longest a cold-empty document holds its loading state waiting for first catchup. */
24023
24199
  const COLD_CONTENT_TIMEOUT_MS = 15e3;
24200
+ /**
24201
+ * Longest a create waits, while connected, for the registry (and a journal's own document) to
24202
+ * catch up before it checks the name again (ADR 0157). Long enough for a reconnect's first
24203
+ * pages, short enough that a slow relay does not hold up a person who is typing.
24204
+ */
24205
+ const CREATE_CONFIRM_MS = 5e3;
24024
24206
  const breathe = createBreather();
24025
24207
  const changeListeners = /* @__PURE__ */ new Set();
24026
24208
  const docsChangedListeners = /* @__PURE__ */ new Set();
@@ -24029,6 +24211,16 @@ function createServerDocumentStore(graph, options) {
24029
24211
  /** docId to the name it had when first seen renamed from elsewhere, and its latest name. */
24030
24212
  const renamesFromElsewhere = /* @__PURE__ */ new Map();
24031
24213
  const resurrectionClaims = /* @__PURE__ */ new Map();
24214
+ /**
24215
+ * Journal entries this device started before its registry had caught up (ADR 0157). A derived
24216
+ * id can already be a registry key that another device set and then deleted while this one was
24217
+ * away. Two concurrent sets of one Y.Map key keep one of them, and when the kept one is the
24218
+ * deleted one, this device's entry goes too: its text would be in the document but no name
24219
+ * would reach it. A claim sets the entry again when that happens, until the registry has
24220
+ * caught up once with this device connected, after which a delete is someone's decision.
24221
+ */
24222
+ const creationClaims = /* @__PURE__ */ new Map();
24223
+ const stopClaimWatches = /* @__PURE__ */ new Set();
24032
24224
  let disposed = false;
24033
24225
  /** Whether the encrypted registry has been read to its terminal relay page this session. */
24034
24226
  let registryConfirmed = false;
@@ -24114,10 +24306,21 @@ function createServerDocumentStore(graph, options) {
24114
24306
  /**
24115
24307
  * Every name a document answers to, to its docId. A page's own name outranks another page's
24116
24308
  * alias of the same name, whatever order the registry lists them in, as the index resolves it.
24309
+ *
24310
+ * A name that more than one document answers to (a [[Duplicate Name]], ADR 0157) keeps the
24311
+ * document it opened before, while that document still answers to it. The registry lists a key
24312
+ * that arrived by sync last, so without this a duplicate arriving would move every open tab
24313
+ * and link of the name onto the other device's document, and two devices would each open the
24314
+ * other's. A name moves only when its document leaves it: deleted, merged or renamed away.
24117
24315
  */
24118
- function currentDocIds() {
24316
+ function currentDocIds(previous) {
24119
24317
  const out = /* @__PURE__ */ new Map();
24120
- registry.forEach((entry, docId) => out.set(conceptKey(conceptOf(entry)), docId));
24318
+ registry.forEach((entry, docId) => {
24319
+ const key = conceptKey(conceptOf(entry));
24320
+ const held = out.get(key);
24321
+ if (held !== void 0 && previous?.get(key) === held) return;
24322
+ out.set(key, docId);
24323
+ });
24121
24324
  registry.forEach((entry, docId) => {
24122
24325
  for (const alias of entry.aliases ?? []) {
24123
24326
  const key = conceptKey(alias);
@@ -24156,7 +24359,7 @@ function createServerDocumentStore(graph, options) {
24156
24359
  const next = currentConcepts();
24157
24360
  for (const [key, concept] of known) if (!next.has(key) && !renamedAway.has(key)) removedListeners.forEach((l) => l(concept));
24158
24361
  known = next;
24159
- docIdsByConcept = currentDocIds();
24362
+ docIdsByConcept = currentDocIds(docIdsByConcept);
24160
24363
  knownRegistry = nextRegistry;
24161
24364
  docsChangedListeners.forEach((l) => l());
24162
24365
  if (!restored) {
@@ -24177,6 +24380,16 @@ function createServerDocumentStore(graph, options) {
24177
24380
  }
24178
24381
  for (const [docId, change] of event.changes.keys) {
24179
24382
  if (change.action !== "delete") continue;
24383
+ const created = creationClaims.get(docId);
24384
+ if (created) queueMicrotask(() => {
24385
+ if (registry.has(docId) || !creationClaims.has(docId)) return;
24386
+ const engine = graph.docSync(docId);
24387
+ if (engine.lifecycle() === "deleting" || engine.doc.getText("content").length === 0) {
24388
+ creationClaims.delete(docId);
24389
+ return;
24390
+ }
24391
+ registry.set(docId, created);
24392
+ });
24180
24393
  const claimed = resurrectionClaims.get(docId);
24181
24394
  if (!claimed) continue;
24182
24395
  queueMicrotask(() => {
@@ -24391,7 +24604,7 @@ function createServerDocumentStore(graph, options) {
24391
24604
  registryConfirmed = true;
24392
24605
  }
24393
24606
  known = currentConcepts();
24394
- docIdsByConcept = currentDocIds();
24607
+ docIdsByConcept = currentDocIds(docIdsByConcept);
24395
24608
  knownRegistry = currentRegistryIdentities();
24396
24609
  },
24397
24610
  listDocuments() {
@@ -24415,6 +24628,9 @@ function createServerDocumentStore(graph, options) {
24415
24628
  pages.sort((a, b) => a.concept.localeCompare(b.concept));
24416
24629
  return [...journals, ...pages];
24417
24630
  },
24631
+ documentIdOf(target) {
24632
+ return docIdFor(target);
24633
+ },
24418
24634
  listIdentities() {
24419
24635
  const out = [];
24420
24636
  registry.forEach((entry, docId) => {
@@ -24685,26 +24901,83 @@ function createServerDocumentStore(graph, options) {
24685
24901
  materialised.release();
24686
24902
  }
24687
24903
  },
24904
+ async mergeDuplicates(docIds, expected, mergeOptions) {
24905
+ const timeoutMs = mergeOptions?.timeoutMs ?? COLD_CONTENT_TIMEOUT_MS;
24906
+ const [first, ...others] = docIds;
24907
+ if (!first || others.length === 0 || !oneName(docIds)) return "gone";
24908
+ if (!graph.isConnected()) return "offline";
24909
+ if (!await confirmRegistry(timeoutMs)) return "unconfirmed";
24910
+ const materialised = await materialise(docIds, timeoutMs);
24911
+ try {
24912
+ if (!oneName(docIds)) return "gone";
24913
+ if (materialised.unconfirmed.length > 0) return "unconfirmed";
24914
+ const texts = docIds.map((id) => graph.docSync(id).doc.getText("content").toString());
24915
+ if (texts.some((text) => documentProtection(text).kind === "document")) return "protected";
24916
+ if (docIds.some((id, index) => texts[index] !== expected.get(id))) return "changed";
24917
+ const firstText = texts[0];
24918
+ const addition = mergedText(["", ...texts.slice(1)]);
24919
+ if (addition !== "" && !firstText.endsWith(addition)) {
24920
+ const merged = mergedText(texts);
24921
+ const ytext = graph.docSync(first).doc.getText("content");
24922
+ ytext.doc.transact(() => ytext.insert(firstText.length, merged.slice(firstText.length)), STORE);
24923
+ }
24924
+ const firstEntry = registry.get(first);
24925
+ const aliases = others.reduce((union, id) => mergeDocuments({
24926
+ body: "",
24927
+ aliases: union
24928
+ }, {
24929
+ body: "",
24930
+ aliases: registry.get(id)?.aliases ?? []
24931
+ }).aliases, firstEntry.aliases ?? []);
24932
+ if (aliases.length !== (firstEntry.aliases ?? []).length) writeAliases(first, firstEntry, aliases);
24933
+ if (!await acknowledged(first, timeoutMs)) return "not-acknowledged";
24934
+ if (others.filter((id, index) => graph.docSync(id).doc.getText("content").toString() !== texts[index + 1]).length > 0 || !oneName(docIds)) return "changed-after-append";
24935
+ for (const id of others) {
24936
+ creationClaims.delete(id);
24937
+ await graph.deleteDoc(id);
24938
+ registry.delete(id);
24939
+ }
24940
+ return "merged";
24941
+ } finally {
24942
+ materialised.release();
24943
+ }
24944
+ },
24688
24945
  async reconcile() {},
24689
24946
  async resolveConflict() {},
24690
- async createJournal(date, body = "") {
24947
+ async createJournal(date, body = "", createOptions) {
24691
24948
  const day = date.trim();
24692
24949
  if (!isJournalConcept(day)) throw new Error(`"${date}" is not a calendar day.`);
24950
+ await confirmBeforeCreate();
24693
24951
  if (docIdFor(day)) throw new Error(`A document for "${day}" already exists`);
24694
- const docId = crypto.randomUUID();
24695
- registry.set(docId, {
24952
+ const docId = await graph.journalDocId(day);
24953
+ const engine = graph.docSync(docId);
24954
+ await graph.whenReady(docId);
24955
+ const connected = graph.isConnected();
24956
+ const caughtUp = connected && await reachedWithin(graph.caughtUpDoc(docId), CREATE_CONFIRM_MS);
24957
+ if (docIdFor(day)) throw new Error(`A document for "${day}" already exists`);
24958
+ const entry = {
24696
24959
  kind: "journal",
24697
24960
  date: day
24698
- });
24699
- if (body !== "") {
24700
- const ytext = graph.docSync(docId).doc.getText("content");
24701
- ytext.doc.transact(() => ytext.insert(0, body));
24702
- }
24961
+ };
24962
+ registry.set(docId, entry);
24963
+ engine.claimCreation();
24964
+ if (!caughtUp) holdCreationClaim(docId, entry);
24965
+ const ytext = engine.doc.getText("content");
24966
+ if (!connected && ytext.length > 0) ytext.doc.transact(() => ytext.delete(0, ytext.length), STORE);
24967
+ else if (connected && !caughtUp && ytext.length > 0) console.warn(`[sync] "${day}" was started before its document caught up; its earlier text is kept`);
24968
+ const template = createOptions?.template ?? "";
24969
+ if (template !== "" && body.startsWith(template)) {
24970
+ await seedTemplate(docId, template);
24971
+ const rest = body.slice(template.length);
24972
+ if (rest !== "") ytext.doc.transact(() => ytext.insert(template.length, rest));
24973
+ } else if (body !== "") ytext.doc.transact(() => ytext.insert(0, body));
24703
24974
  return day;
24704
24975
  },
24705
24976
  async createPage(title, body = "") {
24706
24977
  if (isJournalConcept(title)) throw new Error(dayIsNotAPageName(title));
24707
24978
  if (docIdFor(title)) throw new Error(`A page for "${title}" already exists`);
24979
+ await confirmBeforeCreate();
24980
+ if (docIdFor(title)) throw new Error(`A page for "${title}" already exists`);
24708
24981
  const docId = crypto.randomUUID();
24709
24982
  registry.set(docId, {
24710
24983
  kind: "page",
@@ -24791,6 +25064,7 @@ function createServerDocumentStore(graph, options) {
24791
25064
  async deleteDocument(concept) {
24792
25065
  const docId = docIdFor(concept);
24793
25066
  if (!docId) return;
25067
+ creationClaims.delete(docId);
24794
25068
  await graph.deleteDoc(docId);
24795
25069
  registry.delete(docId);
24796
25070
  },
@@ -24800,10 +25074,90 @@ function createServerDocumentStore(graph, options) {
24800
25074
  unobserveDocUpdates();
24801
25075
  for (const stop of resurrectors.values()) stop();
24802
25076
  resurrectors.clear();
25077
+ for (const stop of stopClaimWatches) stop();
25078
+ stopClaimWatches.clear();
24803
25079
  await graph.flushAll();
24804
25080
  graph.dispose();
24805
25081
  }
24806
25082
  };
25083
+ /**
25084
+ * Confirm before create (ADR 0157): while connected, wait (bounded) for the registry's current
25085
+ * catch-up, so a name another device created moments ago is seen before a second document is
25086
+ * made for it. A reconnect starts a new catch-up cycle a microtask after the socket opens,
25087
+ * which the yield lets begin. Offline nothing can arrive, and the create goes ahead at once.
25088
+ */
25089
+ async function confirmBeforeCreate() {
25090
+ if (!graph.isConnected()) return;
25091
+ await Promise.resolve();
25092
+ await reachedWithin(graph.rootCaughtUp(), CREATE_CONFIRM_MS);
25093
+ }
25094
+ /**
25095
+ * Seed a [[Journal Template]] as one Yjs update whose client id is derived from the document,
25096
+ * its generation and the template's text (ADR 0157). Two devices that start the day with the
25097
+ * same template produce the same struct, which the CRDT keeps once. The generation keeps a day
25098
+ * started again after a delete from reusing the struct the deleted generation held. If the
25099
+ * struct is already here and deleted (a device's cached copy of that generation), it adds
25100
+ * nothing, and the template is inserted as an ordinary edit instead.
25101
+ */
25102
+ async function seedTemplate(docId, template) {
25103
+ const engine = graph.docSync(docId);
25104
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${docId}\n${engine.generation()}\n${template}`)));
25105
+ const seed = new Y.Doc();
25106
+ seed.clientID = new DataView(digest.buffer).getUint32(0);
25107
+ seed.getText("content").insert(0, template);
25108
+ Y.applyUpdate(engine.doc, Y.encodeStateAsUpdate(seed), STORE);
25109
+ seed.destroy();
25110
+ const ytext = engine.doc.getText("content");
25111
+ if (!ytext.toString().startsWith(template)) ytext.doc.transact(() => ytext.insert(0, template), STORE);
25112
+ }
25113
+ /** Whether every one of `docIds` is still in the registry under one name. */
25114
+ function oneName(docIds) {
25115
+ const keys = /* @__PURE__ */ new Set();
25116
+ for (const id of docIds) {
25117
+ const entry = registry.get(id);
25118
+ if (!entry) return false;
25119
+ keys.add(conceptKey(conceptOf(entry)));
25120
+ }
25121
+ return keys.size === 1;
25122
+ }
25123
+ /**
25124
+ * Send a document's pending edits and wait, bounded, until the relay has acknowledged every
25125
+ * one of them: what a Merge waits for before it deletes anything. False when it has not.
25126
+ */
25127
+ async function acknowledged(docId, timeoutMs) {
25128
+ const engine = graph.docSync(docId);
25129
+ await engine.flush();
25130
+ const deadline = Date.now() + timeoutMs;
25131
+ while (engine.unsentOperations() > 0) {
25132
+ if (disposed || Date.now() >= deadline) return false;
25133
+ await new Promise((resolve) => setTimeout(resolve, 50));
25134
+ }
25135
+ return true;
25136
+ }
25137
+ /**
25138
+ * Keep a creation claim until the registry has caught up once with this device connected
25139
+ * (`creationClaims`). Every concurrent set this device's entry could lose to is then here.
25140
+ */
25141
+ function holdCreationClaim(docId, entry) {
25142
+ creationClaims.set(docId, entry);
25143
+ let released = false;
25144
+ const release = () => {
25145
+ if (released) return;
25146
+ released = true;
25147
+ stop();
25148
+ stopClaimWatches.delete(stop);
25149
+ (async () => {
25150
+ await Promise.resolve();
25151
+ await graph.rootCaughtUp();
25152
+ if (creationClaims.get(docId) === entry) creationClaims.delete(docId);
25153
+ })().catch(() => {});
25154
+ };
25155
+ const stop = graph.onActivity((activity) => {
25156
+ if (activity.connection === "open") release();
25157
+ });
25158
+ stopClaimWatches.add(stop);
25159
+ if (graph.isConnected()) release();
25160
+ }
24807
25161
  async function confirmRegistry(timeoutMs = 5e3) {
24808
25162
  if (registryConfirmed) return true;
24809
25163
  if (!graph.isConnected()) return false;
@@ -25185,7 +25539,7 @@ async function openHeadlessGraph(deps) {
25185
25539
  continue;
25186
25540
  }
25187
25541
  if (documentProtection(read.text).kind === "document") continue;
25188
- bodies.set(identity.concept, bodyOf(read.text).body);
25542
+ bodies.set(identity.concept, bodyOf$1(read.text).body);
25189
25543
  }
25190
25544
  return {
25191
25545
  bodies,