@appsoftwareltd/etherpk-mcp 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -34,7 +34,7 @@ import { request } from "node:https";
34
34
  import { BlockList, isIP } from "node:net";
35
35
  var package_default = {
36
36
  name: "@appsoftwareltd/etherpk-mcp",
37
- version: "0.8.1",
37
+ version: "0.8.2",
38
38
  license: "Elastic-2.0",
39
39
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
40
40
  type: "module",
@@ -635,7 +635,9 @@ function createSyncApi(deps) {
635
635
  });
636
636
  if (!res.ok) {
637
637
  const body = await res.json().catch(() => null);
638
- throw new SyncApiError((typeof body?.error === "object" ? body.error.message : body?.code) ?? `HTTP ${res.status}`, res.status, body?.code, body?.retryable === true);
638
+ const nested = typeof body?.error === "object" && body.error !== null ? body.error : null;
639
+ const code = nested?.code ?? body?.code;
640
+ throw new SyncApiError(nested?.message ?? body?.code ?? `HTTP ${res.status}`, res.status, code, body?.retryable === true);
639
641
  }
640
642
  return await res.json();
641
643
  }
@@ -674,8 +676,14 @@ function createSyncApi(deps) {
674
676
  method: "PUT",
675
677
  body: JSON.stringify({ publicKey })
676
678
  }),
677
- getIdentityByEmail: (email) => call(`/api/v1/sync/identity?email=${encodeURIComponent(email)}`).catch((e) => {
678
- if (e instanceof SyncApiError && (e.status === 404 || e.status === 409)) return null;
679
+ getIdentityByEmail: (graphId, email) => call("/api/v1/sync/identity/lookup", {
680
+ method: "POST",
681
+ body: JSON.stringify({
682
+ graphId,
683
+ email
684
+ })
685
+ }).catch((e) => {
686
+ if (e instanceof SyncApiError && e.status === 404) return null;
679
687
  throw e;
680
688
  }),
681
689
  createInvite: (graphId, inviteeEmail, sealedKeyring) => call("/api/v1/sync/invites", {
@@ -688,6 +696,7 @@ function createSyncApi(deps) {
688
696
  }),
689
697
  listInvites: () => call("/api/v1/sync/invites").then((r) => r.invites),
690
698
  acceptInvite: (id) => call(`/api/v1/sync/invites/${id}/accept`, { method: "POST" }).then((r) => r.graphId),
699
+ withdrawInvite: (id) => call(`/api/v1/sync/invites/${id}`, { method: "DELETE" }),
691
700
  graphMembers: (graphId) => call(`/api/v1/sync/graphs/${graphId}/members`).then((r) => r.members),
692
701
  leaveGraph: (graphId) => call(`/api/v1/sync/graphs/${graphId}/leave`, { method: "POST" }),
693
702
  deleteGraph: (graphId) => call(`/api/v1/sync/graphs/${graphId}`, { method: "DELETE" }),
@@ -785,6 +794,36 @@ function createSyncTokenSource(mint, opts) {
785
794
  };
786
795
  }
787
796
  //#endregion
797
+ //#region src/connection-errors.ts
798
+ /** Socket and TLS error codes Node's fetch reports as the cause of "fetch failed". */
799
+ var TLS_CODES = new Set([
800
+ "ERR_SSL_WRONG_VERSION_NUMBER",
801
+ "EPROTO",
802
+ "CERT_HAS_EXPIRED",
803
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
804
+ "SELF_SIGNED_CERT_IN_CHAIN",
805
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
806
+ "ERR_TLS_CERT_ALTNAME_INVALID"
807
+ ]);
808
+ /**
809
+ * Why connecting to a Sync Server failed, in terms a person can act on, or null for a failure
810
+ * this does not recognise. The first mistakes a login makes - the EtherPK app's address in place
811
+ * of the server's, a wrong or revoked token, https against a plain-HTTP server, a mistyped host -
812
+ * otherwise read as "HTTP 404", "Unauthorized" or "fetch failed".
813
+ */
814
+ function describeConnectionFailure(error, syncServer) {
815
+ if (error instanceof SyncApiError) {
816
+ if (error.status === 404 && error.message === "HTTP 404") return `${syncServer} is not a Sync Server (it may be the EtherPK app). Use the Sync Server address shown in EtherPK under a synced graph's Settings > Agents.`;
817
+ if (error.status === 401) return `${syncServer} did not accept the access token: it may be revoked, expired or mistyped. Create one at ${syncServer}/account/tokens and run login again.`;
818
+ return null;
819
+ }
820
+ if (error instanceof TypeError && error.message === "fetch failed") {
821
+ const code = error.cause?.code;
822
+ return `Could not connect to ${syncServer}: ${code === "ENOTFOUND" ? "the host was not found. Check the address." : code === "ECONNREFUSED" ? "the connection was refused. Check the address and that the server is running." : typeof code === "string" && TLS_CODES.has(code) ? "the secure connection failed. Check whether the server uses https or http." : `it did not answer (${typeof code === "string" ? code : "no reason given"}).`}`;
823
+ }
824
+ return null;
825
+ }
826
+ //#endregion
788
827
  //#region src/account.ts
789
828
  /**
790
829
  * The account side of the [[Headless Client]]: the same REST bridge and vault the browser uses,
@@ -806,7 +845,13 @@ function createHeadlessAccount(config) {
806
845
  /** Resolve the PAT to its principal; a graph-scoped or revoked token fails here, in words. */
807
846
  async function connectAccount(config) {
808
847
  const account = createHeadlessAccount(config);
809
- const me = await account.api.me();
848
+ let me;
849
+ try {
850
+ me = await account.api.me();
851
+ } catch (error) {
852
+ const described = describeConnectionFailure(error, config.syncServer);
853
+ throw described ? new Error(described, { cause: error }) : error;
854
+ }
810
855
  return {
811
856
  ...account,
812
857
  principal: {
@@ -2575,7 +2620,7 @@ function createSchema(db) {
2575
2620
  db.exec(SCHEMA$1);
2576
2621
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
2577
2622
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
2578
- db.exec(`PRAGMA user_version = 12`);
2623
+ db.exec(`PRAGMA user_version = 13`);
2579
2624
  }
2580
2625
  function activeIndexGeneration(db) {
2581
2626
  return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
@@ -2596,7 +2641,7 @@ function advanceIndexRevision(db) {
2596
2641
  */
2597
2642
  function isUsableIndex(db) {
2598
2643
  try {
2599
- return db.all("PRAGMA user_version")[0]?.user_version === 12;
2644
+ return db.all("PRAGMA user_version")[0]?.user_version === 13;
2600
2645
  } catch {
2601
2646
  return false;
2602
2647
  }
@@ -2647,8 +2692,12 @@ function hashText(text) {
2647
2692
  function isTextSearchable(block) {
2648
2693
  return !containsCipherFence(block.text);
2649
2694
  }
2650
- /** Write one document's derived rows under an already-established page id. */
2695
+ /**
2696
+ * Write one document's derived rows under an already-established page id. Returns the text of
2697
+ * every row it put into the text index, which `ingestOne` compares with what it replaced.
2698
+ */
2651
2699
  function insertDerived(db, pageId, doc) {
2700
+ const searchable = [];
2652
2701
  for (const alias of doc.aliases) db.run("INSERT INTO aliases (page_id, alias_key, display) VALUES (?,?,?)", [
2653
2702
  pageId,
2654
2703
  conceptKey$1(alias),
@@ -2686,7 +2735,10 @@ function insertDerived(db, pageId, doc) {
2686
2735
  b.label,
2687
2736
  b.text
2688
2737
  ]);
2689
- if (isTextSearchable(b) && b.localId < 1048576) db.run("INSERT INTO block_fts (rowid, text) VALUES (?,?)", [pageId * BLOCK_FTS_STRIDE + b.localId, b.text]);
2738
+ if (isTextSearchable(b) && b.localId < 1048576) {
2739
+ db.run("INSERT INTO block_fts (rowid, text) VALUES (?,?)", [pageId * BLOCK_FTS_STRIDE + b.localId, b.text]);
2740
+ searchable.push(b.text);
2741
+ }
2690
2742
  }
2691
2743
  for (const l of links) db.run("INSERT INTO links (page_id, concept, concept_key, line, line_text, match_start, match_end, block_local_id) VALUES (?,?,?,?,?,?,?,?)", [
2692
2744
  pageId,
@@ -2724,6 +2776,7 @@ function insertDerived(db, pageId, doc) {
2724
2776
  hashText(passage.text),
2725
2777
  passage.text
2726
2778
  ]);
2779
+ return searchable;
2727
2780
  }
2728
2781
  /**
2729
2782
  * Write one document's [[Task Concept]] rows (ADR 0051).
@@ -2768,6 +2821,37 @@ function inTransaction(db, work) {
2768
2821
  throw err;
2769
2822
  }
2770
2823
  }
2824
+ /**
2825
+ * Merge the text index into one segment, which is what removes the words of deleted rows from
2826
+ * the database file (ADR 0097).
2827
+ *
2828
+ * FTS5 deletes by writing a tombstone: the deleted row's terms stay in the segment that holds
2829
+ * them until a merge folds segment and tombstone together, and in a graph nobody is editing that
2830
+ * can be never. `PRAGMA secure_delete` (index-db-sqlite.ts) zeroes the pages a merge frees, but a
2831
+ * segment still in use is not freed. Without a merge, a document protected with content would
2832
+ * leave its words readable with `strings` in the browser's OPFS pool and the Headless Client's
2833
+ * index file, which is the disk-level reader protection exists to stop.
2834
+ *
2835
+ * FTS5's own `secure-delete` option would remove the terms on every delete instead. Measured on a
2836
+ * 2,400-document index it made an edit's re-index 3.5x slower and a generation swap 12.5 s rather
2837
+ * than 0.6 s, for every graph. `optimize` costs about 70 ms there and runs only when protected
2838
+ * text may have been left behind: see {@link ingestOne} and {@link purgeIfProtected}.
2839
+ *
2840
+ * "Protected" here is the index's own flag, `containsCipherFence` anywhere in the text, so it
2841
+ * also covers a page holding a fence beside plaintext (text typed after a protected fence, or a
2842
+ * quoted example), whose plaintext is indexed until the page is protected whole.
2843
+ */
2844
+ function purgeDeletedText(db) {
2845
+ db.run("INSERT INTO block_fts(block_fts) VALUES('optimize')");
2846
+ }
2847
+ /**
2848
+ * After a rebuild: purge when the index now holds any protected document. A rebuild replaces
2849
+ * every row, so the old segments may hold the plaintext of a document that was protected since
2850
+ * the last build, on this device or another. A graph with no protected document pays nothing.
2851
+ */
2852
+ function purgeIfProtected(db, generation) {
2853
+ if (db.all("SELECT 1 AS n FROM pages WHERE generation = ? AND protected = 1 LIMIT 1", [generation]).length > 0) purgeDeletedText(db);
2854
+ }
2771
2855
  /** SQLite has no boolean: the `protected` column as stored. */
2772
2856
  function protectedFlag(doc) {
2773
2857
  return containsCipherFence(doc.text) ? 1 : 0;
@@ -2822,6 +2906,7 @@ function commitIndexRebuild(db, generation) {
2822
2906
  const revision = advanceIndexRevision(db);
2823
2907
  const obsolete = db.all("SELECT DISTINCT generation FROM pages WHERE generation <> ?", [generation]).map((row) => row.generation);
2824
2908
  for (const oldGeneration of obsolete) deleteGeneration(db, oldGeneration);
2909
+ purgeIfProtected(db, generation);
2825
2910
  return revision;
2826
2911
  });
2827
2912
  }
@@ -2857,6 +2942,7 @@ function ingestOne(db, doc) {
2857
2942
  const generation = activeIndexGeneration(db);
2858
2943
  const found = db.all("SELECT id FROM pages WHERE generation = ? AND concept_key = ?", [generation, key]);
2859
2944
  let pageId;
2945
+ const leaving = found.length > 0 && protectedFlag(doc) === 1 ? db.all("SELECT text FROM block_fts WHERE rowid >= ? AND rowid < ?", blockFtsRange(found[0].id)).map((row) => row.text) : [];
2860
2946
  if (found.length > 0) {
2861
2947
  pageId = found[0].id;
2862
2948
  db.run("DELETE FROM aliases WHERE page_id = ?", [pageId]);
@@ -2887,7 +2973,8 @@ function ingestOne(db, doc) {
2887
2973
  protectedFlag(doc)
2888
2974
  ]);
2889
2975
  }
2890
- insertDerived(db, pageId, doc);
2976
+ const written = new Set(insertDerived(db, pageId, doc));
2977
+ if (leaving.some((text) => !written.has(text))) purgeDeletedText(db);
2891
2978
  });
2892
2979
  }
2893
2980
  /** Every concept key that resolves to a real document (canonical name or alias). */
@@ -3218,7 +3305,8 @@ function backlinksFor(db, concept) {
3218
3305
  const hits = db.all(`SELECT l.page_id, p.concept AS sourceConcept, p.kind AS sourceKind, l.line, l.line_text,
3219
3306
  l.match_start, l.match_end, l.block_local_id, l.in_title
3220
3307
  FROM links l JOIN pages p ON p.id = l.page_id
3221
- WHERE p.generation=? AND l.concept_key IN (${placeholders})`, [generation, ...names]);
3308
+ WHERE p.generation=? AND l.concept_key IN (${placeholders})
3309
+ ORDER BY l.page_id, l.line, l.match_start`, [generation, ...names]);
3222
3310
  const blocksByPage = /* @__PURE__ */ new Map();
3223
3311
  for (const pid of new Set(hits.map((h) => h.page_id))) {
3224
3312
  const rows = db.all("SELECT local_id, parent_local_id, ord, kind, depth, done, label, text FROM blocks WHERE page_id=? ORDER BY local_id", [pid]);
@@ -3238,7 +3326,11 @@ function backlinksFor(db, concept) {
3238
3326
  blocksByPage.set(pid, blocks);
3239
3327
  }
3240
3328
  const groups = /* @__PURE__ */ new Map();
3329
+ const places = /* @__PURE__ */ new Set();
3241
3330
  for (const h of hits) {
3331
+ const place = h.in_title ? `${h.page_id}:title` : h.block_local_id != null ? `${h.page_id}:block:${h.block_local_id}` : `${h.page_id}:line:${h.line}`;
3332
+ if (places.has(place)) continue;
3333
+ places.add(place);
3242
3334
  let group = groups.get(h.sourceConcept);
3243
3335
  if (!group) {
3244
3336
  group = {
@@ -3918,7 +4010,7 @@ function cacheRoot(env) {
3918
4010
  * recovered from a joined path (see there).
3919
4011
  */
3920
4012
  var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
3921
- var INDEX_FILE_NAME = `index.v12.sqlite`;
4013
+ var INDEX_FILE_NAME = `index.v13.sqlite`;
3922
4014
  var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
3923
4015
  function cacheFile(dir) {
3924
4016
  return join(dir, CACHE_FILE_NAME);
@@ -4244,6 +4336,65 @@ async function removeCacheRoot(env) {
4244
4336
  force: true
4245
4337
  });
4246
4338
  }
4339
+ var GRAPH_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4340
+ /**
4341
+ * The account a synced graph's cache was written for. Cache directories are keyed by server host
4342
+ * and graph id, not by account, and two agents with their own `ETHERPK_MCP_CONFIG` can sign in as
4343
+ * different accounts on one server and share the cache root. The stamp is what lets one of them
4344
+ * tidy only its own caches.
4345
+ */
4346
+ var OWNER_FILE = "owner.json";
4347
+ /** Record which account's graph this cache holds. Written when a synced graph is opened. */
4348
+ async function stampGraphCacheOwner(dir, principalId) {
4349
+ await mkdir(dir, {
4350
+ recursive: true,
4351
+ mode: 448
4352
+ });
4353
+ await writeFile(join(dir, OWNER_FILE), `${JSON.stringify({ principalId })}\n`, { mode: 384 });
4354
+ }
4355
+ /** The account a cache directory was stamped for, or null (none, or unreadable). */
4356
+ async function graphCacheOwner(dir) {
4357
+ try {
4358
+ const { principalId } = JSON.parse(await readFile(join(dir, OWNER_FILE), "utf8"));
4359
+ return typeof principalId === "string" ? principalId : null;
4360
+ } catch {
4361
+ return null;
4362
+ }
4363
+ }
4364
+ /**
4365
+ * Remove this account's cached graphs of one server that the server no longer lists for it: a
4366
+ * graph deleted, left or taken away since this machine last served it. Its cache is the graph's
4367
+ * plaintext, and without this it would stay on disk until `logout`. `listed` must be the
4368
+ * server's own answer: a failed listing must not reach here, or every cached graph would go.
4369
+ *
4370
+ * Only directories named as a graph id and stamped for `principalId` are touched. Another
4371
+ * account's cache under the same root (its unacknowledged edits included) is not this account's
4372
+ * to drop, and a cache written before the stamp existed is left for `logout`. Returns the ids
4373
+ * removed.
4374
+ */
4375
+ async function removeUnlistedGraphCaches(env, serverBaseUrl, principalId, listed) {
4376
+ const keep = new Set([...listed].map((id) => id.toLowerCase()));
4377
+ const hostDir = dirname(graphCacheDir(env, serverBaseUrl, "x"));
4378
+ let entries;
4379
+ try {
4380
+ entries = await readdir(hostDir, { withFileTypes: true });
4381
+ } catch (error) {
4382
+ if (error.code === "ENOENT") return [];
4383
+ throw error;
4384
+ }
4385
+ const removed = [];
4386
+ for (const entry of entries) {
4387
+ if (!entry.isDirectory() || !GRAPH_ID.test(entry.name) || keep.has(entry.name.toLowerCase())) continue;
4388
+ const dir = join(hostDir, entry.name);
4389
+ if (await graphCacheOwner(dir) !== principalId) continue;
4390
+ await rm(dir, {
4391
+ recursive: true,
4392
+ force: true
4393
+ });
4394
+ removed.push(entry.name);
4395
+ }
4396
+ return removed;
4397
+ }
4247
4398
  /** Remove one server's persisted graphs (logout of that server while others stay). */
4248
4399
  async function removeServerCache(env, serverBaseUrl) {
4249
4400
  await rm(dirname(graphCacheDir(env, serverBaseUrl, "x")), {
@@ -4733,6 +4884,20 @@ var entitlementLimitsSchema = z.strictObject({
4733
4884
  assetBytes: z.int().nonnegative(),
4734
4885
  assetChunks: z.int().nonnegative()
4735
4886
  });
4887
+ /**
4888
+ * Set, and only ever `true`, while the Billing Account's subscription has a payment outstanding
4889
+ * (Stripe `past_due` or `unpaid`). It explains a `grace` or `read_only` status as a failed
4890
+ * payment rather than an ended plan, so the Client can say "fix your payment" instead of "your
4891
+ * subscription has ended". Issuers omit it otherwise, which keeps every other statement readable
4892
+ * by a Sync Server whose strict schema predates the field.
4893
+ */
4894
+ var paymentOverdueSchema = z.literal(true).optional();
4895
+ /**
4896
+ * When the Billing Account's trial ends, set only while the trial runs, so the Client and the Sync
4897
+ * portal can say "Trial, ends 7 Oct" as the Billing page does. Omitted otherwise, as
4898
+ * `paymentOverdue` is.
4899
+ */
4900
+ var trialEndsAtSchema = z.iso.datetime({ offset: true }).optional();
4736
4901
  var serviceEntitlementSchema = z.strictObject({
4737
4902
  eventId: z.uuid(),
4738
4903
  issuer: httpsUrl,
@@ -4749,6 +4914,8 @@ var serviceEntitlementSchema = z.strictObject({
4749
4914
  ]),
4750
4915
  plan: z.string().min(1).max(64),
4751
4916
  limits: entitlementLimitsSchema,
4917
+ paymentOverdue: paymentOverdueSchema,
4918
+ trialEndsAt: trialEndsAtSchema,
4752
4919
  effectiveAt: z.iso.datetime({ offset: true }),
4753
4920
  expiresAt: z.iso.datetime({ offset: true })
4754
4921
  }).refine(({ effectiveAt, expiresAt }) => Date.parse(expiresAt) > Date.parse(effectiveAt), {
@@ -4774,14 +4941,18 @@ z.strictObject({
4774
4941
  id: z.uuid(),
4775
4942
  email: z.email().nullable(),
4776
4943
  name: z.string().min(1).nullable(),
4777
- image: httpsUrl.nullable()
4944
+ image: httpsUrl.nullable(),
4945
+ emailVerified: z.boolean().optional()
4778
4946
  }),
4779
4947
  authentication: syncAuthenticationSchema,
4948
+ invitesNeedVerifiedEmail: z.boolean().optional(),
4780
4949
  clientUrl: httpsUrl.optional(),
4781
4950
  entitlement: z.strictObject({
4782
4951
  plan: z.string().min(1).max(64),
4783
4952
  status: serviceEntitlementSchema.shape.status,
4784
4953
  limits: entitlementLimitsSchema,
4954
+ paymentOverdue: paymentOverdueSchema,
4955
+ trialEndsAt: trialEndsAtSchema,
4785
4956
  usage: z.strictObject({
4786
4957
  ownedGraphs: z.int().nonnegative(),
4787
4958
  ownedStorageBytes: z.int().nonnegative()
@@ -4803,6 +4974,19 @@ var quotaErrorResponseSchema = z.strictObject({
4803
4974
  ownerPrincipalId: z.uuid().optional(),
4804
4975
  retryable: z.literal(true)
4805
4976
  });
4977
+ z.strictObject({
4978
+ eventId: z.uuid(),
4979
+ issuer: httpsUrl,
4980
+ audience: z.literal("urn:etherpk:sync-identity"),
4981
+ subject: z.string().min(1).max(256),
4982
+ revision: z.int().positive(),
4983
+ disabled: z.boolean(),
4984
+ deleted: z.boolean(),
4985
+ credentialsRevokedAt: z.iso.datetime({ offset: true }).nullable(),
4986
+ email: z.email().optional(),
4987
+ emailVerified: z.boolean().optional(),
4988
+ issuedAt: z.iso.datetime({ offset: true })
4989
+ });
4806
4990
  var SYNC_PROTOCOL_LIMITS = Object.freeze({
4807
4991
  maxMessageBytes: 12 * 1024 * 1024,
4808
4992
  maxEnvelopeBytes: 8 * 1024 * 1024,
@@ -5015,6 +5199,7 @@ function serverMessageSchema(limits) {
5015
5199
  code: z.enum(SYNC_ERROR_CODES),
5016
5200
  message: z.string().min(1).max(256),
5017
5201
  docId: uuid.optional(),
5202
+ outboxId: uuid.optional(),
5018
5203
  currentGeneration: generation.optional(),
5019
5204
  quotaCode: quotaErrorCodeSchema.optional(),
5020
5205
  retryable: z.boolean().optional()
@@ -5169,6 +5354,9 @@ var ASSET_CHUNK_PLAINTEXT_BYTES = 4 * 1024 * 1024;
5169
5354
  function assetChunkCount(size) {
5170
5355
  return Math.max(1, Math.ceil(size / ASSET_CHUNK_PLAINTEXT_BYTES));
5171
5356
  }
5357
+ var DAY = 24 * (60 * 6e4);
5358
+ 365 * DAY, 30 * DAY, 7 * DAY;
5359
+ Number.MAX_SAFE_INTEGER;
5172
5360
  //#endregion
5173
5361
  //#region ../client/src/lib/sync/state-vector.ts
5174
5362
  /**
@@ -5210,6 +5398,10 @@ var REMOTE = Symbol("etherpk-remote");
5210
5398
  var CACHE_SEED = Symbol("etherpk-cache-seed");
5211
5399
  /** Structural lifecycle changes which must never be mistaken for a user's edit. */
5212
5400
  var SUPPRESSED = Symbol("etherpk-suppressed");
5401
+ /** Health that says the bytes are not this document's, so neither text nor emptiness is real. */
5402
+ function contentBlocked(health) {
5403
+ return health === "key-unavailable" || health === "ciphertext-corrupt";
5404
+ }
5213
5405
  function newSyncCompletion() {
5214
5406
  let resolvePromise;
5215
5407
  let rejectPromise;
@@ -5296,6 +5488,10 @@ function createDocSync(deps) {
5296
5488
  let pendingSnapshot;
5297
5489
  /** A read-back failed: the next idle uploads a fresh snapshot whatever the tail size. */
5298
5490
  let snapshotRetryWanted = false;
5491
+ /** Tell the graph the queue changed; see `DocSyncDeps.onQueueChange`. */
5492
+ function queueChanged() {
5493
+ if (!destroyed) deps.onQueueChange?.();
5494
+ }
5299
5495
  function requestCatchup(afterSeq) {
5300
5496
  if (!catchupActive) {
5301
5497
  if (catchupCompletion.settled) catchupCompletion = newSyncCompletion();
@@ -5382,6 +5578,7 @@ function createDocSync(deps) {
5382
5578
  }
5383
5579
  async function transmit(operation) {
5384
5580
  await persistTask(() => persist.markAttempt(operation.outboxId, now()));
5581
+ if (durableQueue[0]?.outboxId !== operation.outboxId) return;
5385
5582
  if (operation.kind === "delete") {
5386
5583
  send({
5387
5584
  type: "delete",
@@ -5442,6 +5639,7 @@ function createDocSync(deps) {
5442
5639
  lastAttemptAt: null
5443
5640
  };
5444
5641
  durableQueue.push(durable);
5642
+ queueChanged();
5445
5643
  if (operation.kind === "resurrect") lifecycleState = "resurrecting";
5446
5644
  await transmit(durable);
5447
5645
  }
@@ -5695,6 +5893,7 @@ function createDocSync(deps) {
5695
5893
  baseline.destroy();
5696
5894
  } else lastSyncedSnapshot = Y.snapshot(doc);
5697
5895
  if (lifecycleState === "seeding") lifecycleState = "active";
5896
+ queueChanged();
5698
5897
  },
5699
5898
  async receive(message) {
5700
5899
  switch (message.type) {
@@ -5730,6 +5929,7 @@ function createDocSync(deps) {
5730
5929
  scheduleAutomaticCompaction();
5731
5930
  }
5732
5931
  durableQueue.shift();
5932
+ queueChanged();
5733
5933
  send({
5734
5934
  type: "ack_confirm",
5735
5935
  outboxId: message.outboxId
@@ -5760,6 +5960,7 @@ function createDocSync(deps) {
5760
5960
  }
5761
5961
  await persist.purge();
5762
5962
  durableQueue = [];
5963
+ queueChanged();
5763
5964
  boundarySnapshots.clear();
5764
5965
  recoveredDirtyTokens.clear();
5765
5966
  activeDirtyToken = void 0;
@@ -5825,6 +6026,7 @@ function createDocSync(deps) {
5825
6026
  seeding = false;
5826
6027
  }
5827
6028
  for (const operation of durableQueue.splice(0)) await persist.discard(operation.outboxId);
6029
+ queueChanged();
5828
6030
  boundarySnapshots.clear();
5829
6031
  lastSyncedSnapshot = Y.snapshot(doc);
5830
6032
  const operation = {
@@ -5850,6 +6052,7 @@ function createDocSync(deps) {
5850
6052
  attemptCount: 0,
5851
6053
  lastAttemptAt: null
5852
6054
  });
6055
+ queueChanged();
5853
6056
  await transmit(durableQueue[0]);
5854
6057
  },
5855
6058
  lifecycle: () => lifecycleState,
@@ -5857,6 +6060,7 @@ function createDocSync(deps) {
5857
6060
  async staleGeneration(currentGeneration) {
5858
6061
  syncHealth = "generation-stale";
5859
6062
  for (const operation of durableQueue.splice(0)) await persist.discard(operation.outboxId);
6063
+ queueChanged();
5860
6064
  boundarySnapshots.clear();
5861
6065
  documentGeneration = currentGeneration;
5862
6066
  lastSeq = 0;
@@ -5877,6 +6081,12 @@ function createDocSync(deps) {
5877
6081
  await sendAwareness([doc.clientID]);
5878
6082
  },
5879
6083
  isIdle: () => durableQueue.length === 0 && !debounceTimer && !drainPromise,
6084
+ unsentOperations: () => durableQueue.length,
6085
+ retryUnsent() {
6086
+ if (destroyed) return;
6087
+ if (durableQueue[0]) detached(transmit(durableQueue[0]));
6088
+ else detached(requestDrain());
6089
+ },
5880
6090
  destroy() {
5881
6091
  destroyed = true;
5882
6092
  boundarySnapshots.clear();
@@ -6066,6 +6276,29 @@ function createPresenceSession(identity, options = {}) {
6066
6276
  }
6067
6277
  };
6068
6278
  }
6279
+ var RECONNECT_CAP_MS = 3e4;
6280
+ /** The wait before reconnect attempt `attempt` (1 for the first retry). */
6281
+ function reconnectDelayMs(attempt, random = Math.random) {
6282
+ const exponent = Math.min(Math.max(0, attempt - 1), 16);
6283
+ const ceiling = Math.min(RECONNECT_CAP_MS, 500 * 2 ** exponent);
6284
+ return Math.floor(random() * ceiling);
6285
+ }
6286
+ /**
6287
+ * How long a graph waits before sending a write the Sync Server refused on a quota again: a
6288
+ * lapsed plan, a storage allowance, or a plan the server could not confirm. The refusal is an
6289
+ * answer, not an outage, so the schedule is slower than a reconnect's: the ceiling doubles from
6290
+ * ten seconds to two minutes. Half of it is fixed and half drawn below it, so a refused client
6291
+ * is never back within milliseconds and a crowd refused together spreads out. `round` counts
6292
+ * the retries already made for this refusal, from 1. The workspace also retries at once when the
6293
+ * person comes back to the tab, which is when a restarted plan usually shows.
6294
+ */
6295
+ var REFUSAL_RETRY_BASE_MS = 1e4;
6296
+ var REFUSAL_RETRY_CAP_MS = 12e4;
6297
+ function refusalRetryDelayMs(round, random = Math.random) {
6298
+ const exponent = Math.min(Math.max(0, round - 1), 16);
6299
+ const ceiling = Math.min(REFUSAL_RETRY_CAP_MS, REFUSAL_RETRY_BASE_MS * 2 ** exponent);
6300
+ return Math.floor(ceiling / 2 + random() * (ceiling / 2));
6301
+ }
6069
6302
  //#endregion
6070
6303
  //#region ../client/src/lib/document/quick-notes.ts
6071
6304
  /** A hard ceiling, so a corrupt or hostile list cannot swamp the View or the root doc. */
@@ -6478,16 +6711,65 @@ function graphThemeFromFiles(id, name, files, origin, now = /* @__PURE__ */ new
6478
6711
  *
6479
6712
  * The transport is injected (a `connect(url)` factory) so this unit-tests without real sockets.
6480
6713
  */
6481
- /** Reconnect after a dropped socket (simple; backoff tuned later). */
6482
- var RECONNECT_MS = 50;
6483
- /** Retry after a token mint failed - a server round trip, so slower than a reconnect. */
6484
- var TOKEN_RETRY_MS = 2e3;
6714
+ /** The HTTP status a token source's refusal carries (SyncApiError, ManagedTokenError), if any. */
6715
+ function refusalStatus(error) {
6716
+ const status = typeof error === "object" && error !== null ? error.status : void 0;
6717
+ return typeof status === "number" ? status : void 0;
6718
+ }
6719
+ /** How long activity changes are gathered before listeners hear them: an import acks thousands. */
6720
+ var ACTIVITY_COALESCE_MS = 50;
6485
6721
  /** Internal signal: a watermark request is safe to repeat on the next socket generation. */
6486
6722
  var WatermarkConnectionInterruptedError = class extends Error {};
6723
+ /**
6724
+ * The relay did not answer in time: the socket reads open, but nothing reaches the server, as on a
6725
+ * connection that dropped without closing. Named so a caller can say "can't reach the Sync Server"
6726
+ * rather than repeat the protocol's words.
6727
+ */
6728
+ var RelayUnansweredError = class extends Error {};
6487
6729
  function createGraphSync(deps) {
6488
6730
  const engines = /* @__PURE__ */ new Map();
6489
6731
  const retained = new Map([[deps.rootDocId, 1]]);
6490
6732
  const retiring = /* @__PURE__ */ new Set();
6733
+ /**
6734
+ * Batch walks holding an engine: one count per `seedDocsFromCache` or `readyDocs` not yet
6735
+ * matched by a `retireDocs`. Walks overlap - a publish's read, the Local Mirror's pass and
6736
+ * the index's catch-up can all be over the same document - so a count decides retirement.
6737
+ * With a plain flag, the first walk to finish would retire the engine while another still
6738
+ * waited on its catch-up; the catch-up's completion would destroy it, the waiter's
6739
+ * `caughtUpDoc` would resolve, and the waiter would read the empty engine created in its
6740
+ * place (a cold `etherpk-mcp publish` would read every page as empty and "settled").
6741
+ */
6742
+ const batchHolds = /* @__PURE__ */ new Map();
6743
+ function holdForBatch(docIds) {
6744
+ for (const docId of docIds) {
6745
+ if (docId === deps.rootDocId) continue;
6746
+ batchHolds.set(docId, (batchHolds.get(docId) ?? 0) + 1);
6747
+ }
6748
+ }
6749
+ /**
6750
+ * Wait for a held batch's cache reads. The holds stay taken whether or not they succeed: the
6751
+ * caller releases the batch it asked for, once, in its own `finally`. Releasing here as well
6752
+ * would release a failed batch twice, and the second release would take the hold of another
6753
+ * walk over the same document.
6754
+ */
6755
+ async function awaitHeld(docIds) {
6756
+ await Promise.all(docIds.map((docId) => readied.get(docId) ?? Promise.resolve()));
6757
+ }
6758
+ /** One hold per document back; an engine no walk holds or retains is retired once idle. */
6759
+ function releaseBatch(docIds) {
6760
+ for (const docId of docIds) {
6761
+ if (docId === deps.rootDocId) continue;
6762
+ const holds = (batchHolds.get(docId) ?? 0) - 1;
6763
+ if (holds > 0) {
6764
+ batchHolds.set(docId, holds);
6765
+ continue;
6766
+ }
6767
+ batchHolds.delete(docId);
6768
+ if (retained.has(docId)) continue;
6769
+ retiring.add(docId);
6770
+ retireEngineIfIdle(docId);
6771
+ }
6772
+ }
6491
6773
  const presenceSession = deps.presence ? createPresenceSession({
6492
6774
  name: deps.presence.name,
6493
6775
  color: deps.presence.color,
@@ -6499,6 +6781,42 @@ function createGraphSync(deps) {
6499
6781
  let socket;
6500
6782
  let open = false;
6501
6783
  let disposed = false;
6784
+ /** Set once access has ended for good; nothing reconnects after it. */
6785
+ let lost = null;
6786
+ /** See `SyncActivity.connection`. */
6787
+ let connection = "connecting";
6788
+ /** Documents whose engines hold unacknowledged outbox operations, kept by `onQueueChange`. */
6789
+ const unsentDocs = /* @__PURE__ */ new Set();
6790
+ /**
6791
+ * Write refusals. The relay answers a refused append, delete or resurrect with `quota_denied`
6792
+ * naming the document and operation, and acknowledges nothing: the operation stays at the
6793
+ * head of that document's outbox, and without a retry the document would stay stalled until
6794
+ * a reload even after the allowance came back. `refusedDocs` are the documents to send
6795
+ * again; empty when a server too old to name them refused.
6796
+ */
6797
+ let refusal = null;
6798
+ const refusedDocs = /* @__PURE__ */ new Set();
6799
+ /** Bumped by every refusal, so `awaitAcked` can tell one of its own writes from an older one. */
6800
+ let refusalCount = 0;
6801
+ let refusalRounds = 0;
6802
+ let refusalTimer;
6803
+ const refusalDelay = deps.refusalRetryDelayMs ?? ((round) => refusalRetryDelayMs(round));
6804
+ const activityListeners = /* @__PURE__ */ new Set();
6805
+ let activityTimer;
6806
+ const snapshotActivity = () => ({
6807
+ connection,
6808
+ unsentDocuments: unsentDocs.size,
6809
+ refusal
6810
+ });
6811
+ function activityChanged() {
6812
+ if (disposed || activityTimer || activityListeners.size === 0) return;
6813
+ activityTimer = setTimeout(() => {
6814
+ activityTimer = void 0;
6815
+ if (disposed) return;
6816
+ const current = snapshotActivity();
6817
+ for (const listener of activityListeners) listener(current);
6818
+ }, ACTIVITY_COALESCE_MS);
6819
+ }
6502
6820
  /**
6503
6821
  * Set when the Sync Server turned out to speak another protocol version. The session then
6504
6822
  * stops for good: every reconnect would meet the same server, and nothing either side
@@ -6509,6 +6827,10 @@ function createGraphSync(deps) {
6509
6827
  if (!disposed) deps.onError?.(error instanceof Error ? error : new Error(String(error)));
6510
6828
  };
6511
6829
  const outboundSnapshots = [];
6830
+ /** Documents whose operation this socket has sent and the relay has not answered, to its outbox id. */
6831
+ const operationsInFlight = /* @__PURE__ */ new Map();
6832
+ /** Each document's operation waiting for room in {@link OPERATION_WINDOW}, oldest first. */
6833
+ const operationsWaiting = /* @__PURE__ */ new Map();
6512
6834
  const foregroundCatchups = [];
6513
6835
  const backgroundCatchups = [];
6514
6836
  let catchupInFlight;
@@ -6526,6 +6848,7 @@ function createGraphSync(deps) {
6526
6848
  function waitForCurrentConnection() {
6527
6849
  if (open && socket) return Promise.resolve();
6528
6850
  if (disposed) return Promise.reject(/* @__PURE__ */ new Error("the graph sync session was disposed"));
6851
+ if (lost) return Promise.reject(/* @__PURE__ */ new Error("access to this graph has ended"));
6529
6852
  if (protocolMismatch) return Promise.reject(protocolMismatch);
6530
6853
  return new Promise((resolve, reject) => {
6531
6854
  connectionWaiters.add({
@@ -6544,7 +6867,7 @@ function createGraphSync(deps) {
6544
6867
  const requestId = crypto.randomUUID();
6545
6868
  const timer = setTimeout(() => {
6546
6869
  watermarkRequests.delete(requestId);
6547
- reject(/* @__PURE__ */ new Error("the sync relay did not answer a watermark check"));
6870
+ reject(new RelayUnansweredError("the sync relay did not answer a watermark check"));
6548
6871
  }, 15e3);
6549
6872
  watermarkRequests.set(requestId, {
6550
6873
  resolve,
@@ -6558,7 +6881,7 @@ function createGraphSync(deps) {
6558
6881
  }));
6559
6882
  });
6560
6883
  } catch (error) {
6561
- if (error instanceof WatermarkConnectionInterruptedError && !disposed) continue;
6884
+ if (error instanceof WatermarkConnectionInterruptedError && !disposed && !lost) continue;
6562
6885
  throw error;
6563
6886
  }
6564
6887
  }
@@ -6620,6 +6943,37 @@ function createGraphSync(deps) {
6620
6943
  pumpCatchups();
6621
6944
  retireEngineIfIdle(completedDocId);
6622
6945
  }
6946
+ /**
6947
+ * Send a document's operation within the window, or hold it until an answer makes room. A
6948
+ * document's newer operation replaces one still waiting (the engine sends only its head).
6949
+ */
6950
+ function sendOperation(docId, message) {
6951
+ ackRoute.set(message.outboxId, docId);
6952
+ if (!open || !socket) return;
6953
+ if (!operationsInFlight.has(docId) && operationsInFlight.size >= 128) {
6954
+ operationsWaiting.set(docId, message);
6955
+ return;
6956
+ }
6957
+ operationsWaiting.delete(docId);
6958
+ operationsInFlight.set(docId, message.outboxId);
6959
+ sendNow(message);
6960
+ }
6961
+ /**
6962
+ * The relay answered `docId`'s operation, by acking or refusing it: its place in the window
6963
+ * goes to the longest-waiting one. `outboxId`, when the answer names one, must be the
6964
+ * operation in flight; an answer to an older one frees nothing.
6965
+ */
6966
+ function operationAnswered(docId, outboxId) {
6967
+ const inFlight = operationsInFlight.get(docId);
6968
+ if (inFlight === void 0 || outboxId !== void 0 && inFlight !== outboxId) return;
6969
+ operationsInFlight.delete(docId);
6970
+ for (const [waitingDocId, waiting] of operationsWaiting) {
6971
+ if (!open || !socket || operationsInFlight.size >= 128) return;
6972
+ operationsWaiting.delete(waitingDocId);
6973
+ operationsInFlight.set(waitingDocId, waiting.outboxId);
6974
+ sendNow(waiting);
6975
+ }
6976
+ }
6623
6977
  function rawSend(message) {
6624
6978
  if (message.type === "catchup") {
6625
6979
  queueCatchup(message);
@@ -6655,7 +7009,7 @@ function createGraphSync(deps) {
6655
7009
  presenceDetach.delete(docId);
6656
7010
  }
6657
7011
  function retireEngineIfIdle(docId) {
6658
- if (docId === deps.rootDocId || retained.has(docId) || !retiring.has(docId)) return;
7012
+ if (docId === deps.rootDocId || retained.has(docId) || batchHolds.has(docId) || !retiring.has(docId)) return;
6659
7013
  if (catchupInFlight?.docId === docId || foregroundCatchups.some((request) => request.docId === docId) || backgroundCatchups.some((request) => request.docId === docId)) return;
6660
7014
  const candidate = engines.get(docId);
6661
7015
  if (!candidate || !candidate.isIdle()) return;
@@ -6663,6 +7017,7 @@ function createGraphSync(deps) {
6663
7017
  detachPresence(docId);
6664
7018
  candidate.destroy();
6665
7019
  engines.delete(docId);
7020
+ unsentDocs.delete(docId);
6666
7021
  readied.delete(docId);
6667
7022
  syncEnabled.delete(docId);
6668
7023
  performanceRecorder.mark("sync.engine.count", {
@@ -6670,6 +7025,69 @@ function createGraphSync(deps) {
6670
7025
  subscriptions: retained.size
6671
7026
  });
6672
7027
  }
7028
+ function noteQueue(docId) {
7029
+ const e = engines.get(docId);
7030
+ if (e && e.unsentOperations() > 0) unsentDocs.add(docId);
7031
+ else unsentDocs.delete(docId);
7032
+ activityChanged();
7033
+ }
7034
+ /**
7035
+ * The relay refused a write on a quota. A refused snapshot is not a stalled write - the relay
7036
+ * still holds the document's log, and the engine uploads another at a later idle - so only a
7037
+ * refused outbox operation (named by its outbox id), or a refusal a server too old to name
7038
+ * anything sent, counts.
7039
+ */
7040
+ function noteRefusal(message) {
7041
+ if (message.docId && !message.outboxId) return;
7042
+ if (message.docId) refusedDocs.add(message.docId);
7043
+ refusal = message.quotaCode ? { quotaCode: message.quotaCode } : {};
7044
+ refusalCount += 1;
7045
+ scheduleRefusalRetry();
7046
+ activityChanged();
7047
+ }
7048
+ function scheduleRefusalRetry() {
7049
+ if (refusalTimer || disposed || lost) return;
7050
+ refusalTimer = setTimeout(() => {
7051
+ refusalTimer = void 0;
7052
+ refusalRounds += 1;
7053
+ resendRefused();
7054
+ }, refusalDelay(refusalRounds + 1));
7055
+ }
7056
+ /** Send each refused document's head operation again; the relay applies an outbox id once. */
7057
+ function resendRefused() {
7058
+ if (disposed || lost || !open) return;
7059
+ const docIds = refusedDocs.size > 0 ? [...refusedDocs] : [...unsentDocs];
7060
+ for (const docId of docIds) {
7061
+ const e = engines.get(docId);
7062
+ if (!e || e.unsentOperations() === 0) {
7063
+ refusedDocs.delete(docId);
7064
+ continue;
7065
+ }
7066
+ e.retryUnsent();
7067
+ }
7068
+ if (refusedDocs.size === 0 && unsentDocs.size === 0) clearRefusal();
7069
+ }
7070
+ /** Whether every document with unsent operations is one the server refused. */
7071
+ function everyUnsentRefused() {
7072
+ if (unsentDocs.size === 0) return false;
7073
+ if (refusedDocs.size === 0) return true;
7074
+ for (const docId of unsentDocs) if (!refusedDocs.has(docId)) return false;
7075
+ return true;
7076
+ }
7077
+ /** An ack: the server accepted a write, so that document is refused no longer. */
7078
+ function writeAccepted(docId) {
7079
+ if (!refusal) return;
7080
+ refusedDocs.delete(docId);
7081
+ if (refusedDocs.size === 0) clearRefusal();
7082
+ }
7083
+ function clearRefusal() {
7084
+ refusal = null;
7085
+ refusedDocs.clear();
7086
+ refusalRounds = 0;
7087
+ clearTimeout(refusalTimer);
7088
+ refusalTimer = void 0;
7089
+ activityChanged();
7090
+ }
6673
7091
  function engine(docId, synchronize = true) {
6674
7092
  let e = engines.get(docId);
6675
7093
  if (!e) {
@@ -6678,7 +7096,10 @@ function createGraphSync(deps) {
6678
7096
  graphId: deps.graphId,
6679
7097
  keyring: deps.keyring,
6680
7098
  send: (message) => {
6681
- if (message.type === "append" || message.type === "delete" || message.type === "resurrect") ackRoute.set(message.outboxId, docId);
7099
+ if (message.type === "append" || message.type === "delete" || message.type === "resurrect") {
7100
+ sendOperation(docId, message);
7101
+ return;
7102
+ }
6682
7103
  rawSend(message);
6683
7104
  },
6684
7105
  persist: deps.cache.docCache(docId, docId === deps.rootDocId ? "root" : "document"),
@@ -6686,6 +7107,7 @@ function createGraphSync(deps) {
6686
7107
  collectRowWhen: docId === deps.rootDocId ? void 0 : deps.collectRowWhen,
6687
7108
  onError: reportError,
6688
7109
  onIdle: () => queueMicrotask(() => retireEngineIfIdle(docId)),
7110
+ onQueueChange: () => noteQueue(docId),
6689
7111
  catchupPriority: () => {
6690
7112
  return retained.has(docId) ? "foreground" : "background";
6691
7113
  }
@@ -6782,6 +7204,11 @@ function createGraphSync(deps) {
6782
7204
  function stopForProtocolMismatch(serverVersion) {
6783
7205
  if (protocolMismatch) return;
6784
7206
  protocolMismatch = new SyncProtocolMismatchError(serverVersion);
7207
+ connection = "ended";
7208
+ clearTimeout(reconnectTimer);
7209
+ clearTimeout(refusalTimer);
7210
+ refusalTimer = void 0;
7211
+ activityChanged();
6785
7212
  reportError(protocolMismatch);
6786
7213
  for (const waiter of connectionWaiters) waiter.reject(protocolMismatch);
6787
7214
  connectionWaiters.clear();
@@ -6801,8 +7228,18 @@ function createGraphSync(deps) {
6801
7228
  continuation: message.hasMore ? 1 : 0
6802
7229
  });
6803
7230
  if (message.type === "error") {
7231
+ if (message.code === "membership_revoked") {
7232
+ endAccess({ kind: "membership" });
7233
+ return;
7234
+ }
7235
+ if (message.code === "quota_denied") {
7236
+ if (message.docId) operationAnswered(message.docId, message.outboxId);
7237
+ noteRefusal(message);
7238
+ return;
7239
+ }
6804
7240
  if (message.code === "stale_generation" && message.docId && message.currentGeneration !== void 0) {
6805
7241
  if (catchupInFlight?.docId === message.docId) catchupInFlight = void 0;
7242
+ operationAnswered(message.docId);
6806
7243
  engine(message.docId).staleGeneration(message.currentGeneration);
6807
7244
  pumpCatchups();
6808
7245
  }
@@ -6824,7 +7261,13 @@ function createGraphSync(deps) {
6824
7261
  }
6825
7262
  if (message.type === "ack") {
6826
7263
  const docId = ackRoute.get(message.outboxId);
6827
- if (docId) engine(docId).receive(message).then(() => ackRoute.delete(message.outboxId)).catch(report);
7264
+ if (docId) {
7265
+ operationAnswered(docId, message.outboxId);
7266
+ engine(docId).receive(message).then(() => {
7267
+ ackRoute.delete(message.outboxId);
7268
+ writeAccepted(docId);
7269
+ }).catch(report);
7270
+ }
6828
7271
  return;
6829
7272
  }
6830
7273
  if (message.type === "catchup_batch") {
@@ -6834,20 +7277,78 @@ function createGraphSync(deps) {
6834
7277
  engine(message.docId).receive(message).catch(report);
6835
7278
  }
6836
7279
  /**
7280
+ * Forget everything that belonged to one socket generation. Durable IndexedDB rows, not these
7281
+ * entries, determine outstanding work and replay on the next open, which resubscribes every
7282
+ * retained document.
7283
+ */
7284
+ function forgetSocketGeneration() {
7285
+ subscribed.clear();
7286
+ ackRoute.clear();
7287
+ operationsInFlight.clear();
7288
+ operationsWaiting.clear();
7289
+ catchupInFlight = void 0;
7290
+ foregroundCatchups.length = 0;
7291
+ backgroundCatchups.length = 0;
7292
+ for (const pending of watermarkRequests.values()) {
7293
+ clearTimeout(pending.timer);
7294
+ pending.reject(new WatermarkConnectionInterruptedError());
7295
+ }
7296
+ watermarkRequests.clear();
7297
+ }
7298
+ let attempts = 0;
7299
+ let openedAt = 0;
7300
+ let forceToken = false;
7301
+ let credentialRefusals = 0;
7302
+ let reconnectTimer;
7303
+ const retryDelay = deps.retryDelayMs ?? ((attempt) => reconnectDelayMs(attempt));
7304
+ function scheduleConnect(delayMs) {
7305
+ if (disposed || lost || protocolMismatch) return;
7306
+ clearTimeout(reconnectTimer);
7307
+ reconnectTimer = setTimeout(connect, delayMs);
7308
+ }
7309
+ function backOff() {
7310
+ attempts += 1;
7311
+ scheduleConnect(retryDelay(attempts));
7312
+ }
7313
+ /** Stop for good: close the socket, never reconnect, and tell the owner once. */
7314
+ function endAccess(loss) {
7315
+ if (lost || disposed) return;
7316
+ lost = loss;
7317
+ connection = "ended";
7318
+ clearTimeout(reconnectTimer);
7319
+ clearTimeout(refusalTimer);
7320
+ refusalTimer = void 0;
7321
+ activityChanged();
7322
+ const current = socket;
7323
+ socket = void 0;
7324
+ open = false;
7325
+ forgetSocketGeneration();
7326
+ current?.close();
7327
+ for (const waiter of connectionWaiters) waiter.reject(/* @__PURE__ */ new Error("access to this graph has ended"));
7328
+ connectionWaiters.clear();
7329
+ deps.onAccessLost?.(loss);
7330
+ }
7331
+ /**
6837
7332
  * A token is fetched for EVERY connect, never captured: the source re-mints when the
6838
- * held token nears expiry, so a socket that drops an hour in still reconnects. Sends
7333
+ * held token nears expiry, so a socket that drops an hour in still reconnects.
6839
7334
  * Encrypted appends issued while the token is in flight remain in IndexedDB and replay
6840
7335
  * on open. Only rebuildable snapshot uploads use a volatile queue.
6841
7336
  */
6842
7337
  function connect() {
6843
- if (disposed || protocolMismatch) return;
6844
- deps.token().then((token) => {
6845
- if (disposed) return;
7338
+ if (disposed || lost || protocolMismatch) return;
7339
+ const force = forceToken;
7340
+ forceToken = false;
7341
+ deps.token(force ? { force: true } : void 0).then((token) => {
7342
+ if (disposed || lost) return;
6846
7343
  const s = deps.connect(`${deps.relayUrl}?token=${encodeURIComponent(token)}`);
6847
7344
  socket = s;
6848
7345
  s.onOpen(() => {
6849
- if (disposed || socket !== s) return;
7346
+ if (disposed || lost || socket !== s) return;
6850
7347
  open = true;
7348
+ connection = "open";
7349
+ activityChanged();
7350
+ openedAt = Date.now();
7351
+ credentialRefusals = 0;
6851
7352
  resolveFirstOpen?.();
6852
7353
  for (const waiter of connectionWaiters) waiter.resolve();
6853
7354
  connectionWaiters.clear();
@@ -6868,24 +7369,45 @@ function createGraphSync(deps) {
6868
7369
  }
6869
7370
  });
6870
7371
  s.onMessage(handleMessage);
6871
- s.onClose(() => {
7372
+ s.onClose((event) => {
6872
7373
  if (socket !== s) return;
7374
+ const upFor = open ? Date.now() - openedAt : 0;
6873
7375
  open = false;
6874
7376
  socket = void 0;
6875
- subscribed.clear();
6876
- ackRoute.clear();
6877
- catchupInFlight = void 0;
6878
- foregroundCatchups.length = 0;
6879
- backgroundCatchups.length = 0;
6880
- for (const pending of watermarkRequests.values()) {
6881
- clearTimeout(pending.timer);
6882
- pending.reject(new WatermarkConnectionInterruptedError());
7377
+ forgetSocketGeneration();
7378
+ if (disposed || lost || protocolMismatch) return;
7379
+ connection = "reconnecting";
7380
+ activityChanged();
7381
+ if (event?.code === 4403) {
7382
+ endAccess({ kind: "membership" });
7383
+ return;
7384
+ }
7385
+ if (upFor >= 1e4) attempts = 0;
7386
+ if (event?.code === 4401) {
7387
+ forceToken = true;
7388
+ credentialRefusals += 1;
7389
+ if (credentialRefusals === 1) {
7390
+ scheduleConnect(0);
7391
+ return;
7392
+ }
6883
7393
  }
6884
- watermarkRequests.clear();
6885
- if (!disposed && !protocolMismatch) setTimeout(connect, RECONNECT_MS);
7394
+ backOff();
6886
7395
  });
6887
- }, () => {
6888
- if (!disposed) setTimeout(connect, TOKEN_RETRY_MS);
7396
+ }, (error) => {
7397
+ if (disposed || lost) return;
7398
+ const status = refusalStatus(error);
7399
+ if (status === 401) {
7400
+ endAccess({
7401
+ kind: "credentials",
7402
+ cause: error
7403
+ });
7404
+ return;
7405
+ }
7406
+ if (status === 403) {
7407
+ endAccess({ kind: "membership" });
7408
+ return;
7409
+ }
7410
+ backOff();
6889
7411
  });
6890
7412
  }
6891
7413
  connect();
@@ -7064,21 +7586,17 @@ function createGraphSync(deps) {
7064
7586
  await readied.get(docId);
7065
7587
  },
7066
7588
  async seedDocsFromCache(docIds) {
7589
+ holdForBatch(docIds);
7067
7590
  const batch = docIds.map((docId) => engine(docId, false));
7068
- await Promise.all(docIds.map((docId) => readied.get(docId) ?? Promise.resolve()));
7591
+ await awaitHeld(docIds);
7069
7592
  return batch.map((entry) => entry.doc);
7070
7593
  },
7071
7594
  async readyDocs(docIds) {
7595
+ holdForBatch(docIds);
7072
7596
  for (const docId of docIds) engine(docId);
7073
- await Promise.all(docIds.map((docId) => readied.get(docId) ?? Promise.resolve()));
7074
- },
7075
- retireDocs(docIds) {
7076
- for (const docId of docIds) {
7077
- if (docId === deps.rootDocId || retained.has(docId)) continue;
7078
- retiring.add(docId);
7079
- retireEngineIfIdle(docId);
7080
- }
7597
+ await awaitHeld(docIds);
7081
7598
  },
7599
+ retireDocs: releaseBatch,
7082
7600
  firstCatchupPageDoc: (docId) => engine(docId).firstCatchupPage(),
7083
7601
  caughtUpDoc: (docId) => engine(docId).caughtUp(),
7084
7602
  async docsNeedingCatchup(docIds) {
@@ -7105,12 +7623,24 @@ function createGraphSync(deps) {
7105
7623
  };
7106
7624
  },
7107
7625
  isConnected: () => open,
7626
+ activity: snapshotActivity,
7627
+ onActivity(listener) {
7628
+ activityListeners.add(listener);
7629
+ return () => activityListeners.delete(listener);
7630
+ },
7631
+ retryRefused() {
7632
+ if (!refusal) return;
7633
+ clearTimeout(refusalTimer);
7634
+ refusalTimer = void 0;
7635
+ resendRefused();
7636
+ },
7108
7637
  async flushAll({ onProgress } = {}) {
7109
7638
  const all = [...engines.values()];
7110
7639
  let flushed = 0;
7111
7640
  await Promise.all(all.map((e) => e.flush().then(() => onProgress?.(++flushed, all.length))));
7112
7641
  },
7113
7642
  async awaitAcked({ onProgress, signal, stallMs = 3e4 } = {}) {
7643
+ const refusalsBefore = refusalCount;
7114
7644
  const initial = await deps.cache.countPending();
7115
7645
  if (initial === 0) return {
7116
7646
  settled: true,
@@ -7138,13 +7668,19 @@ function createGraphSync(deps) {
7138
7668
  settled: true,
7139
7669
  outstanding: 0
7140
7670
  });
7671
+ if (refusal && (refusalCount > refusalsBefore || everyUnsentRefused())) return finish({
7672
+ settled: false,
7673
+ outstanding: current,
7674
+ refused: refusal
7675
+ });
7141
7676
  if (signal?.aborted) return finish({
7142
7677
  settled: false,
7143
7678
  outstanding: current
7144
7679
  });
7145
7680
  if (Date.now() - lastProgressAt >= stallMs) return finish({
7146
7681
  settled: false,
7147
- outstanding: current
7682
+ outstanding: current,
7683
+ ...refusal ? { refused: refusal } : {}
7148
7684
  });
7149
7685
  timer = setTimeout(poll, POLL_MS);
7150
7686
  } catch (error) {
@@ -7162,14 +7698,21 @@ function createGraphSync(deps) {
7162
7698
  activeEngines: engines.size,
7163
7699
  retainedDocuments: retained.size
7164
7700
  }),
7701
+ endAccess,
7702
+ accessLoss: () => lost,
7165
7703
  dispose() {
7166
7704
  disposed = true;
7705
+ clearTimeout(reconnectTimer);
7706
+ clearTimeout(refusalTimer);
7707
+ clearTimeout(activityTimer);
7708
+ activityListeners.clear();
7167
7709
  socket?.close();
7168
7710
  for (const detach of presenceDetach.values()) detach();
7169
7711
  presenceDetach.clear();
7170
7712
  for (const e of engines.values()) e.destroy();
7171
7713
  engines.clear();
7172
7714
  retiring.clear();
7715
+ batchHolds.clear();
7173
7716
  syncEnabled.clear();
7174
7717
  foregroundCatchups.length = 0;
7175
7718
  backgroundCatchups.length = 0;
@@ -7649,7 +8192,10 @@ function nodeTransport(url) {
7649
8192
  close: () => ws.close(),
7650
8193
  onOpen: (cb) => ws.addEventListener("open", () => cb()),
7651
8194
  onMessage: (cb) => ws.addEventListener("message", (e) => cb(String(e.data))),
7652
- onClose: (cb) => ws.addEventListener("close", () => cb())
8195
+ onClose: (cb) => ws.addEventListener("close", (event) => cb({
8196
+ code: event.code,
8197
+ reason: event.reason
8198
+ }))
7653
8199
  };
7654
8200
  }
7655
8201
  //#endregion
@@ -9065,6 +9611,20 @@ var IMAGE_EXTS = new Set([
9065
9611
  ]);
9066
9612
  /** A doc-relative asset reference: optional `../` hops, then `assets/<name>`. */
9067
9613
  var ASSET_REF$1 = /^(?:\.\.\/)*assets\/(.+)$/;
9614
+ /**
9615
+ * An asset could not be fetched just now: the connection failed, or the server answered with a
9616
+ * status that means "try again". It says nothing about whether the file exists, so something
9617
+ * showing the file asks again rather than calling it missing. `status` is the server's answer,
9618
+ * absent when the connection failed before there was one.
9619
+ */
9620
+ var AssetUnavailableError = class extends Error {
9621
+ name = "AssetUnavailableError";
9622
+ status;
9623
+ constructor(message, options = {}) {
9624
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
9625
+ this.status = options.status;
9626
+ }
9627
+ };
9068
9628
  /** Split a file name into its stem and lower-cased extension (`''` ext when there is none). */
9069
9629
  function splitNameExt(fileName) {
9070
9630
  const dot = fileName.lastIndexOf(".");
@@ -9504,6 +10064,25 @@ async function deleteGraphTheme(adapter, id) {
9504
10064
  * Pure: no store, no editor. The YAML rules (what counts as a block, how it parses) come from
9505
10065
  * `storage/fs`, shared with the scan and the editor's analysis, so there is one rule.
9506
10066
  */
10067
+ /** What the block claims. An unterminated block is not a block, as everywhere else. */
10068
+ function frontmatterIdentity(text) {
10069
+ const span = frontmatterSpan(text);
10070
+ const data = span ? parseBlock$2(span.body) : {};
10071
+ if (data === null) return {
10072
+ title: null,
10073
+ aliases: [],
10074
+ hasAliasesKey: false,
10075
+ hasBlock: true,
10076
+ readable: false
10077
+ };
10078
+ return {
10079
+ title: titleOf(data),
10080
+ aliases: aliasesOf({ data }),
10081
+ hasAliasesKey: "aliases" in data,
10082
+ hasBlock: span !== null,
10083
+ readable: true
10084
+ };
10085
+ }
9507
10086
  function titleOf(data) {
9508
10087
  const title = data.title;
9509
10088
  return typeof title === "string" && title.trim() !== "" ? title : null;
@@ -9572,6 +10151,19 @@ function withFrontmatterIdentity(text, patch, options = {}) {
9572
10151
  if (wantAliases.length > 0 && !("aliases" in data)) next.aliases = wantAliases;
9573
10152
  return `---\n${serialise(next)}---\n${text.slice(span.end)}`;
9574
10153
  }
10154
+ /**
10155
+ * `after` - a writer's rewrite of `before` - with the document's aliases carried into the block
10156
+ * the rewrite added. A writer that gives a document its first block, as Publish does when it adds
10157
+ * `public:`, must not make the document claim fewer aliases than it has: a block with no
10158
+ * `aliases:` line claims none, and the next edit to it would clear them (ADR 0061). Only a Server
10159
+ * Backend has aliases without a block; on a Filesystem Backend they are read from the saved file,
10160
+ * so `aliases` is empty there provided the caller wrote pending edits before reading them
10161
+ * (`rewriteFrontmatter` flushes first). A block `before` already had is left as the writer left it.
10162
+ */
10163
+ function withAliasesInAddedBlock(before, after, aliases) {
10164
+ if (aliases.length === 0 || frontmatterSpan(before) !== null || frontmatterSpan(after) === null) return after;
10165
+ return withFrontmatterIdentity(after, { aliases });
10166
+ }
9575
10167
  /** The block's YAML as a plain object, or null when it is malformed or not an object. */
9576
10168
  function parseBlock$2(yaml) {
9577
10169
  try {
@@ -10019,6 +10611,31 @@ async function refuseProtectedMerges(plan, isProtected) {
10019
10611
  return plan;
10020
10612
  }
10021
10613
  /**
10614
+ * Refuse a plan whose steps would rewrite a [[Frontmatter]] block that does not parse. A
10615
+ * Filesystem Backend rebuilds each renamed document's block, and a merge survivor's, from its
10616
+ * parsed data, which is empty for such a block, so every key it holds would be lost from the file:
10617
+ * the only copy. `textOf` gives a document's current text (an open buffer, else its file), or
10618
+ * null for a name with no document behind it.
10619
+ */
10620
+ async function refuseUnreadableBlocks(plan, textOf) {
10621
+ if (plan.refusal) return plan;
10622
+ for (const step of renameSteps(plan)) for (const concept of step.merges ? [step.from, step.into] : [step.from]) {
10623
+ const text = await textOf(concept);
10624
+ if (text === null || frontmatterIdentity(text).readable) continue;
10625
+ return {
10626
+ ...plan,
10627
+ refusal: unreadableBlockRefusal(plan.direct.from, concept)
10628
+ };
10629
+ }
10630
+ return plan;
10631
+ }
10632
+ /** Why `renamed` cannot be renamed: the block of `unreadable` (itself, or a page the rename rewrites) does not parse. */
10633
+ function unreadableBlockRefusal(renamed, unreadable) {
10634
+ const fix = "Fix the block (a property written twice, or a line left half typed) and rename again.";
10635
+ if (conceptKey(renamed) === conceptKey(unreadable)) return `“${renamed}” cannot be renamed while its frontmatter is not valid YAML: renaming rewrites the block, and what it holds would be lost. ${fix}`;
10636
+ return `“${renamed}” cannot be renamed while the frontmatter of “${unreadable}”, which the rename rewrites, is not valid YAML: what it holds would be lost. ${fix}`;
10637
+ }
10638
+ /**
10022
10639
  * A step merges when a DIFFERENT document already answers to the target name - by title or by
10023
10640
  * alias - and the source has a document to join to it. Renaming onto yourself (a pure
10024
10641
  * re-casing, or onto one of your own aliases) is neither. A source with no document landing
@@ -10343,6 +10960,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10343
10960
  await settled(openDoc);
10344
10961
  }
10345
10962
  const { text } = await adapter.read(entry.subdir, entry.fileName);
10963
+ if (!frontmatterIdentity(text).readable) throw new Error(unreadableBlockRefusal(step.from, entry.concept));
10346
10964
  const fm = parseFrontmatter(text);
10347
10965
  let aliases = aliasesOf(fm);
10348
10966
  let body = fm.body;
@@ -10353,7 +10971,9 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10353
10971
  const targetEntry = step.merges ? registry.get(conceptKey(step.into)) : void 0;
10354
10972
  const survivor = targetEntry && targetEntry.key !== entry.key ? targetEntry : void 0;
10355
10973
  if (survivor) {
10356
- const existingFm = parseFrontmatter((await adapter.read(survivor.subdir, survivor.fileName)).text);
10974
+ const existing = await adapter.read(survivor.subdir, survivor.fileName);
10975
+ if (!frontmatterIdentity(existing.text).readable) throw new Error(unreadableBlockRefusal(step.from, survivor.concept));
10976
+ const existingFm = parseFrontmatter(existing.text);
10357
10977
  const merged = mergeDocuments({
10358
10978
  body: existingFm.body,
10359
10979
  aliases: aliasesOf(existingFm)
@@ -10462,6 +11082,29 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10462
11082
  if (doc.dirty) runSave(doc);
10463
11083
  await settled(doc);
10464
11084
  }
11085
+ /**
11086
+ * The registry entry `target` names: a document by its own name, else by one of its aliases
11087
+ * (ADR 0061), as a bookmark or a Recents entry can carry a renamed page's old name. A page's own
11088
+ * name outranks another page's alias of the same name, as the index resolves it.
11089
+ */
11090
+ function entryNamed(target) {
11091
+ const key = conceptKey(target);
11092
+ const own = registry.get(key);
11093
+ if (own) return own;
11094
+ for (const entry of registry.values()) if (entry.aliases.some((alias) => conceptKey(alias) === key)) return entry;
11095
+ }
11096
+ /** The open document `target` names, opening it if it is not; throws when nothing has that name. */
11097
+ function openDocNamed(target) {
11098
+ const existing = open.get(conceptKey(target));
11099
+ if (existing) return existing;
11100
+ const entry = entryNamed(target);
11101
+ if (!entry) throw new DocumentNotFoundError(target);
11102
+ const opened = open.get(entry.key);
11103
+ if (opened) return opened;
11104
+ const doc = makeOpenDoc(entry.key === conceptKey(target) ? target : entry.concept, entry);
11105
+ open.set(entry.key, doc);
11106
+ return doc;
11107
+ }
10465
11108
  function makeOpenDoc(target, entry) {
10466
11109
  const doc = {
10467
11110
  key: entry.key,
@@ -10565,17 +11208,10 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10565
11208
  }
10566
11209
  return {
10567
11210
  open(target) {
10568
- const key = conceptKey(target);
10569
- const existing = open.get(key);
10570
- if (existing) return existing.handle;
10571
- const entry = registry.get(key);
10572
- if (!entry) throw new DocumentNotFoundError(target);
10573
- const doc = makeOpenDoc(target, entry);
10574
- open.set(key, doc);
10575
- return doc.handle;
11211
+ return openDocNamed(target).handle;
10576
11212
  },
10577
11213
  async whenReady(target) {
10578
- await (open.get(conceptKey(target)) ?? (this.open(target), open.get(conceptKey(target))))?.ready;
11214
+ await openDocNamed(target).ready;
10579
11215
  },
10580
11216
  async scan() {
10581
11217
  await adapter.ensureSkeleton();
@@ -10724,7 +11360,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10724
11360
  return concept;
10725
11361
  },
10726
11362
  async planRename(from, to, referencingDocuments) {
10727
- return refuseProtectedMerges(planRename$1({
11363
+ return refuseUnreadableBlocks(await refuseProtectedMerges(planRename$1({
10728
11364
  from,
10729
11365
  to,
10730
11366
  kind: registry.get(conceptKey(from))?.kind ?? null,
@@ -10738,6 +11374,12 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10738
11374
  const other = registry.get(conceptKey(concept));
10739
11375
  if (!other) return false;
10740
11376
  return documentProtection(open.get(other.key)?.buffer ?? (await adapter.read(other.subdir, other.fileName)).text).kind === "document";
11377
+ }), async (concept) => {
11378
+ const other = registry.get(conceptKey(concept));
11379
+ if (!other) return null;
11380
+ const openDoc = open.get(other.key);
11381
+ if (openDoc) await settled(openDoc);
11382
+ return (await adapter.read(other.subdir, other.fileName)).text;
10741
11383
  });
10742
11384
  },
10743
11385
  async renamePage(from, to, options) {
@@ -10800,6 +11442,11 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10800
11442
  if (!doc) return;
10801
11443
  await saveNow(doc);
10802
11444
  },
11445
+ async flushAll() {
11446
+ await Promise.all([...open.values()].map(saveNow));
11447
+ const unwritten = [...open.values()].filter((doc) => doc.dirty).map((doc) => doc.target);
11448
+ if (unwritten.length > 0) throw new Error(`Edits to ${unwritten.join(", ")} could not be written to the folder yet, so it does not hold them. Try again once they are saved.`);
11449
+ },
10803
11450
  async dispose() {
10804
11451
  await Promise.all([...open.values()].map(saveNow));
10805
11452
  open.clear();
@@ -11000,10 +11647,6 @@ async function reachedWithin(promise, timeoutMs) {
11000
11647
  if (timer) clearTimeout(timer);
11001
11648
  }
11002
11649
  }
11003
- /** Health that says the bytes are not this document's, so neither text nor emptiness is real. */
11004
- function contentBlocked(health) {
11005
- return health === "key-unavailable" || health === "ciphertext-corrupt";
11006
- }
11007
11650
  function createServerDocumentStore(graph, options) {
11008
11651
  const registry = graph.registry();
11009
11652
  /** Engines created between yields while snapshotting — small enough to keep frames free. */
@@ -11014,6 +11657,9 @@ function createServerDocumentStore(graph, options) {
11014
11657
  const changeListeners = /* @__PURE__ */ new Set();
11015
11658
  const docsChangedListeners = /* @__PURE__ */ new Set();
11016
11659
  const removedListeners = /* @__PURE__ */ new Set();
11660
+ const renamedListeners = /* @__PURE__ */ new Set();
11661
+ /** docId to the name it had when first seen renamed from elsewhere, and its latest name. */
11662
+ const renamesFromElsewhere = /* @__PURE__ */ new Map();
11017
11663
  const resurrectionClaims = /* @__PURE__ */ new Map();
11018
11664
  let disposed = false;
11019
11665
  /** Whether the encrypted registry has been read to its terminal relay page this session. */
@@ -11033,9 +11679,14 @@ function createServerDocumentStore(graph, options) {
11033
11679
  */
11034
11680
  async function materialise(docIds, timeoutMs) {
11035
11681
  const unique = [...new Set(docIds)];
11036
- const release = () => graph.retireDocs(unique);
11682
+ const held = [];
11683
+ const release = () => graph.retireDocs(held);
11037
11684
  try {
11038
- for (let start = 0; start < unique.length; start += ENGINE_BATCH) await graph.readyDocs(unique.slice(start, start + ENGINE_BATCH));
11685
+ for (let start = 0; start < unique.length; start += ENGINE_BATCH) {
11686
+ const batch = unique.slice(start, start + ENGINE_BATCH);
11687
+ held.push(...batch);
11688
+ await graph.readyDocs(batch);
11689
+ }
11039
11690
  const connected = graph.isConnected();
11040
11691
  const behind = new Set(connected ? await graph.docsNeedingCatchup(unique) : []);
11041
11692
  const unconfirmed = [];
@@ -11092,11 +11743,18 @@ function createServerDocumentStore(graph, options) {
11092
11743
  });
11093
11744
  return out;
11094
11745
  }
11746
+ /**
11747
+ * Every name a document answers to, to its docId. A page's own name outranks another page's
11748
+ * alias of the same name, whatever order the registry lists them in, as the index resolves it.
11749
+ */
11095
11750
  function currentDocIds() {
11096
11751
  const out = /* @__PURE__ */ new Map();
11752
+ registry.forEach((entry, docId) => out.set(conceptKey(conceptOf(entry)), docId));
11097
11753
  registry.forEach((entry, docId) => {
11098
- out.set(conceptKey(conceptOf(entry)), docId);
11099
- for (const alias of entry.aliases ?? []) out.set(conceptKey(alias), docId);
11754
+ for (const alias of entry.aliases ?? []) {
11755
+ const key = conceptKey(alias);
11756
+ if (!out.has(key)) out.set(key, docId);
11757
+ }
11100
11758
  });
11101
11759
  return out;
11102
11760
  }
@@ -11105,25 +11763,43 @@ function createServerDocumentStore(graph, options) {
11105
11763
  const nextRegistry = currentRegistryIdentities();
11106
11764
  const changedDocIds = [...new Set([...knownRegistry.keys(), ...nextRegistry.keys()])].filter((docId) => !sameIdentity(knownRegistry.get(docId), nextRegistry.get(docId)));
11107
11765
  if (changedDocIds.length === 0) return;
11766
+ const restored = event.transaction.origin === CACHE_SEED;
11767
+ const fromElsewhere = !restored && !event.transaction.local;
11108
11768
  const additions = [];
11769
+ const renames = [];
11109
11770
  let additionsOnly = true;
11110
11771
  for (const docId of changedDocIds) {
11111
11772
  const before = knownRegistry.get(docId);
11112
11773
  const after = nextRegistry.get(docId);
11774
+ if (fromElsewhere && before && after && before.concept !== after.concept) renames.push({
11775
+ docId,
11776
+ from: before.concept,
11777
+ to: after.concept
11778
+ });
11113
11779
  if (before || !after) {
11114
11780
  additionsOnly = false;
11115
11781
  continue;
11116
11782
  }
11117
11783
  additions.push(after.concept);
11118
11784
  }
11785
+ const renamedAway = new Set(renames.map((rename) => conceptKey(rename.from)));
11119
11786
  const next = currentConcepts();
11120
- for (const [key, concept] of known) if (!next.has(key)) removedListeners.forEach((l) => l(concept));
11787
+ for (const [key, concept] of known) if (!next.has(key) && !renamedAway.has(key)) removedListeners.forEach((l) => l(concept));
11121
11788
  known = next;
11122
11789
  docIdsByConcept = currentDocIds();
11123
11790
  knownRegistry = nextRegistry;
11124
11791
  docsChangedListeners.forEach((l) => l());
11125
- if (additionsOnly) for (const concept of additions) changeListeners.forEach((listener) => listener({ concept }));
11792
+ if (!restored) if (additionsOnly) for (const concept of additions) changeListeners.forEach((listener) => listener({ concept }));
11126
11793
  else changeListeners.forEach((listener) => listener());
11794
+ for (const { docId, from, to } of renames) {
11795
+ const first = renamesFromElsewhere.get(docId)?.from ?? from;
11796
+ if (first === to) renamesFromElsewhere.delete(docId);
11797
+ else renamesFromElsewhere.set(docId, {
11798
+ from: first,
11799
+ to
11800
+ });
11801
+ renamedListeners.forEach((listener) => listener(from, to));
11802
+ }
11127
11803
  for (const [docId, change] of event.changes.keys) {
11128
11804
  if (change.action !== "delete") continue;
11129
11805
  const claimed = resurrectionClaims.get(docId);
@@ -11401,6 +12077,18 @@ function createServerDocumentStore(graph, options) {
11401
12077
  }
11402
12078
  return out;
11403
12079
  },
12080
+ async docsBehind(docIds, timeoutMs = COLD_CONTENT_TIMEOUT_MS) {
12081
+ if (disposed || !graph.isConnected()) return null;
12082
+ if (docIds.length === 0) return [];
12083
+ let timer;
12084
+ try {
12085
+ return await Promise.race([graph.docsNeedingCatchup(docIds).catch(() => null), new Promise((resolve) => {
12086
+ timer = setTimeout(() => resolve(null), timeoutMs);
12087
+ })]);
12088
+ } finally {
12089
+ if (timer) clearTimeout(timer);
12090
+ }
12091
+ },
11404
12092
  async confirmRegistry(timeoutMs = 5e3) {
11405
12093
  if (registryConfirmed) return true;
11406
12094
  if (!graph.isConnected()) return false;
@@ -11415,6 +12103,13 @@ function createServerDocumentStore(graph, options) {
11415
12103
  removedListeners.add(listener);
11416
12104
  return () => removedListeners.delete(listener);
11417
12105
  },
12106
+ onDocumentRenamed(listener) {
12107
+ renamedListeners.add(listener);
12108
+ return () => renamedListeners.delete(listener);
12109
+ },
12110
+ renamesObserved() {
12111
+ return [...renamesFromElsewhere.values()].map((rename) => ({ ...rename }));
12112
+ },
11418
12113
  onChange(listener) {
11419
12114
  changeListeners.add(listener);
11420
12115
  return () => changeListeners.delete(listener);
@@ -11528,11 +12223,16 @@ function createServerDocumentStore(graph, options) {
11528
12223
  acknowledge: checkpoint.acknowledge
11529
12224
  };
11530
12225
  },
11531
- snapshotDocument(concept) {
12226
+ async snapshotDocument(concept) {
11532
12227
  const docId = docIdFor(concept);
11533
- const entry = docId ? registry.get(docId) : void 0;
11534
- if (!docId || !entry) return null;
11535
- return indexSnapshotFor(entry, graph.docSync(docId).doc.getText("content").toString());
12228
+ if (!docId || !registry.has(docId)) return null;
12229
+ try {
12230
+ await graph.readyDocs([docId]);
12231
+ const entry = registry.get(docId);
12232
+ return entry ? indexSnapshotFor(entry, graph.docSync(docId).doc.getText("content").toString()) : null;
12233
+ } finally {
12234
+ graph.retireDocs([docId]);
12235
+ }
11536
12236
  },
11537
12237
  async compactDocument(target) {
11538
12238
  const docId = docIdFor(target);
@@ -11799,7 +12499,7 @@ async function withRetry(task, options = {}) {
11799
12499
  } catch (error) {
11800
12500
  if (options.signal?.aborted) throw options.signal.reason;
11801
12501
  if (attempt >= attempts || !shouldRetry(error)) throw error;
11802
- const full = base * 2 ** (attempt - 1);
12502
+ const full = Math.min(base * 2 ** (attempt - 1), options.maxDelayMs ?? Number.POSITIVE_INFINITY);
11803
12503
  const wait = Math.round(full / 2 + random() * (full / 2));
11804
12504
  options.onRetry?.(error, attempt, wait);
11805
12505
  await sleep(wait, options.signal);
@@ -11889,7 +12589,11 @@ var HttpFailure = class extends Error {
11889
12589
  */
11890
12590
  function isTransient(error) {
11891
12591
  if (!(error instanceof HttpFailure)) return true;
11892
- return error.status === 401 || error.status === 408 || error.status === 429 || error.status >= 500;
12592
+ return isTransientStatus(error.status);
12593
+ }
12594
+ /** "Not your fault, try again": a 401 only once a fresh token has been presented. */
12595
+ function isTransientStatus(status) {
12596
+ return status === 401 || status === 408 || status === 429 || status >= 500;
11893
12597
  }
11894
12598
  function kebabStem(name) {
11895
12599
  return name.replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "asset";
@@ -11960,14 +12664,33 @@ function createServerAssetStore(deps) {
11960
12664
  }, retryOptions);
11961
12665
  }
11962
12666
  /**
11963
- * Fetch, decrypt and reassemble one asset. Both read paths go through here: the mirror
11964
- * wants the bytes themselves, a viewer wants them behind an object URL.
12667
+ * A read request whose connection failure is a file that cannot be fetched just now, not a
12668
+ * missing one. A cancellation stays a cancellation.
12669
+ */
12670
+ async function readRequest(url, init) {
12671
+ try {
12672
+ return await f(url, init);
12673
+ } catch (error) {
12674
+ if (error instanceof DOMException && error.name === "AbortError") throw error;
12675
+ throw new AssetUnavailableError("The file could not be fetched: the connection failed.", { cause: error });
12676
+ }
12677
+ }
12678
+ /**
12679
+ * Fetch, decrypt and reassemble one asset; both read paths go through here. `null` when the
12680
+ * server has no such asset or refuses it. A failure that says nothing about the asset rejects
12681
+ * with `AssetUnavailableError`: the connection, or a status that means "try again" (a 401
12682
+ * after one fresh token). A file that will not decrypt or parse rejects with that error.
11965
12683
  */
11966
- async function readAssetBytes(ref) {
12684
+ async function fetchAssetBytes(ref) {
11967
12685
  const assetId = assetIdFromRef(ref);
11968
12686
  if (!assetId) return null;
11969
- const res = await f(`${base}/api/v1/sync/assets/${deps.graphId}/${assetId}`, { headers: { "x-sync-token": await deps.syncToken() } });
11970
- if (!res.ok) return null;
12687
+ const url = `${base}/api/v1/sync/assets/${deps.graphId}/${assetId}`;
12688
+ let res = await readRequest(url, { headers: { "x-sync-token": await deps.syncToken() } });
12689
+ if (res.status === 401) res = await readRequest(url, { headers: { "x-sync-token": await deps.syncToken({ force: true }) } });
12690
+ if (!res.ok) {
12691
+ if (isTransientStatus(res.status)) throw new AssetUnavailableError(`The sync server could not hand over the file just now (HTTP ${res.status}).`, { status: res.status });
12692
+ return null;
12693
+ }
11971
12694
  const body = await res.json();
11972
12695
  const metaPlain = await openSymmetric({
11973
12696
  keyForEpoch: (id) => keyForEpoch(deps.keyring, id),
@@ -11978,8 +12701,11 @@ function createServerAssetStore(deps) {
11978
12701
  const perAssetKey = fromBase64Url(metadata.perAssetKey);
11979
12702
  const parts = [];
11980
12703
  for (let n = 0; n < body.downloadUrls.length; n++) {
11981
- const chunkRes = await f(body.downloadUrls[n]);
11982
- if (!chunkRes.ok) return null;
12704
+ const chunkRes = await readRequest(body.downloadUrls[n]);
12705
+ if (!chunkRes.ok) {
12706
+ if (chunkRes.status !== 401 && isTransientStatus(chunkRes.status)) throw new AssetUnavailableError(`Storage could not hand over the file just now (HTTP ${chunkRes.status}).`, { status: chunkRes.status });
12707
+ return null;
12708
+ }
11983
12709
  const { plaintext } = await openSymmetric({
11984
12710
  keyForEpoch: () => perAssetKey,
11985
12711
  envelope: new Uint8Array(await chunkRes.arrayBuffer()),
@@ -12069,7 +12795,7 @@ function createServerAssetStore(deps) {
12069
12795
  body: chunks[n]
12070
12796
  });
12071
12797
  } catch {
12072
- throw new Error("Uploading to the storage bucket was blocked. The bucket's CORS policy must allow this app's origin - see \"Asset storage\" in the Synced Graphs doc.");
12798
+ throw new Error(typeof navigator !== "undefined" && navigator.onLine === false ? "The connection dropped while uploading to the storage bucket." : "Uploading to the storage bucket was blocked. The bucket's CORS policy must allow this app's origin - see \"Asset storage\" in the Synced Graphs doc.");
12073
12799
  }
12074
12800
  if (!r.ok) throw new HttpFailure(r.status, `chunk ${n} upload failed: ${r.status}`);
12075
12801
  onBytes?.(Math.min(CHUNK_SIZE, bytes.length - n * CHUNK_SIZE));
@@ -12085,9 +12811,9 @@ function createServerAssetStore(deps) {
12085
12811
  });
12086
12812
  return saved(assetId, false);
12087
12813
  },
12088
- readBytes: readAssetBytes,
12814
+ readBytes: (ref) => fetchAssetBytes(ref).catch((error) => error instanceof AssetUnavailableError && error.status !== void 0 ? null : Promise.reject(error)),
12089
12815
  async resolve(ref) {
12090
- const asset = await readAssetBytes(ref);
12816
+ const asset = await fetchAssetBytes(ref);
12091
12817
  if (!asset) return null;
12092
12818
  const url = URL.createObjectURL(new Blob([asset.bytes], { type: asset.type }));
12093
12819
  objectUrls.push(url);
@@ -12129,6 +12855,20 @@ async function listServerAssets(deps) {
12129
12855
  return assets;
12130
12856
  }
12131
12857
  //#endregion
12858
+ //#region ../client/src/lib/sync/write-refusal.ts
12859
+ var AGENT_REASONS = {
12860
+ entitlement_inactive: "the graph owner's plan does not allow changes at the moment (a lapsed, unpaid or unconfirmed plan)",
12861
+ owned_storage_limit: "the graph owner's storage allowance is used up"
12862
+ };
12863
+ /**
12864
+ * The Headless Client's refusal, for an agent: the edit was refused, not lost in transit, and it
12865
+ * needs no retry from the agent. The connection-loss wording ("will be delivered when the
12866
+ * connection recovers") is untrue of a refusal.
12867
+ */
12868
+ function writeRefusalForAgent(refusal, outstanding) {
12869
+ return `The Sync Server refused the edit: ${(refusal.quotaCode && AGENT_REASONS[refusal.quotaCode]) ?? "a plan limit was reached"}. ${outstanding === 1 ? "1 operation is held" : `${outstanding} operations are held`} here and sent automatically once the server accepts changes again.`;
12870
+ }
12871
+ //#endregion
12132
12872
  //#region src/headless-graph.ts
12133
12873
  /**
12134
12874
  * How often the cache and index are exported while a build is running. A build reports
@@ -12247,6 +12987,7 @@ function assembleHeadlessGraph(parts) {
12247
12987
  publishing: parts.publishing,
12248
12988
  themes: parts.themes,
12249
12989
  settle: () => parts.settle(schedulePersist),
12990
+ accessLoss: () => parts.accessLoss?.() ?? null,
12250
12991
  persist,
12251
12992
  semantic,
12252
12993
  semanticOpened: () => semanticOpening ? semanticOpening.catch(() => void 0) : Promise.resolve(void 0),
@@ -12282,6 +13023,7 @@ async function openHeadlessGraph(deps) {
12282
13023
  ...PRESENCE_PALETTE[0]
12283
13024
  },
12284
13025
  onError: deps.onError,
13026
+ onAccessLost: deps.onAccessLost,
12285
13027
  publishName: deps.publishName
12286
13028
  });
12287
13029
  const store = createServerDocumentStore(sync, { readyTimeoutMs: deps.readyTimeoutMs });
@@ -12364,6 +13106,7 @@ async function openHeadlessGraph(deps) {
12364
13106
  return assembleHeadlessGraph({
12365
13107
  graphId: deps.graphId,
12366
13108
  name: sync.getMeta().name ?? deps.graphId,
13109
+ accessLoss: () => sync.accessLoss(),
12367
13110
  store: documents,
12368
13111
  index,
12369
13112
  assets,
@@ -12399,6 +13142,12 @@ async function openHeadlessGraph(deps) {
12399
13142
  const result = await sync.awaitAcked({ stallMs: 1e4 });
12400
13143
  schedulePersist();
12401
13144
  if (result.settled) return { settled: true };
13145
+ if (result.refused) return {
13146
+ settled: false,
13147
+ outstanding: result.outstanding,
13148
+ code: "write_refused",
13149
+ message: writeRefusalForAgent(result.refused, result.outstanding)
13150
+ };
12402
13151
  return {
12403
13152
  settled: false,
12404
13153
  outstanding: result.outstanding,
@@ -12676,6 +13425,43 @@ async function openHeadlessFolder(deps) {
12676
13425
  }
12677
13426
  }
12678
13427
  //#endregion
13428
+ //#region ../client/src/lib/sync/recovery-unlock.ts
13429
+ /**
13430
+ * Unlock with a Recovery Code, checked against the account's vault.
13431
+ *
13432
+ * A Recovery Code derives the vault's wrap key, and any well-formed code derives *a* key. Caching
13433
+ * whatever came out would report a mistyped or retired code as success, and every graph would
13434
+ * then fail to open with a message that blamed the keys on the device, next to the control that
13435
+ * resets them. The browser and the Headless Client both use this one check: fetch the vault,
13436
+ * open it with the derived key, and hand back the vault key only once it has opened.
13437
+ *
13438
+ * What is returned is the vault key, never the wrap key, as Device Approval caches: it keeps
13439
+ * working after the Recovery Code is regenerated on another device.
13440
+ */
13441
+ /** The account has no vault yet, so there is nothing for a Recovery Code to open. */
13442
+ var NoVaultError = class extends Error {
13443
+ constructor() {
13444
+ super("This account has no encryption keys yet; there is nothing for a Recovery Code to open.");
13445
+ this.name = "NoVaultError";
13446
+ }
13447
+ };
13448
+ /**
13449
+ * @throws RecoveryCodeError when the code is malformed or does not open this account's vault.
13450
+ * @throws NoVaultError when the account has no vault.
13451
+ * Anything else (the vault could not be fetched) propagates unchanged: it says nothing about the code.
13452
+ */
13453
+ async function openVaultWithRecoveryCode(api, code) {
13454
+ const wrapKey = await deriveVaultWrapKey(code);
13455
+ const stored = await api.getVault();
13456
+ if (!stored) throw new NoVaultError();
13457
+ try {
13458
+ return (await openVault(fromBase64Url(stored.vault), wrapKey)).vaultKey;
13459
+ } catch (error) {
13460
+ if (error instanceof EnvelopeError) throw new RecoveryCodeError("That Recovery Code does not open this account’s keys. Check it character by character; only the most recently issued code works.");
13461
+ throw error;
13462
+ }
13463
+ }
13464
+ //#endregion
12679
13465
  //#region ../client/src/lib/sync/device-approval.ts
12680
13466
  /**
12681
13467
  * Device approval (ADR 0026 flows): unlock a NEW device from an already-unlocked one, so
@@ -12747,18 +13533,66 @@ var ApprovalAbandoned = class extends Error {
12747
13533
  /** Approval rows expire server-side after ten minutes; poll a little longer and then give up. */
12748
13534
  var APPROVAL_TIMEOUT_MS = 11 * 6e4;
12749
13535
  var APPROVAL_POLL_MS = 2e3;
13536
+ /** How long a quit waits for the server to cancel the approval before leaving anyway. */
13537
+ var CANCEL_TIMEOUT_MS = 5e3;
13538
+ /** Shell exit codes for the signals that end a login: 128 plus the signal's number. */
13539
+ var SIGNAL_EXIT_CODES = {
13540
+ SIGHUP: 129,
13541
+ SIGINT: 130,
13542
+ SIGTERM: 143
13543
+ };
13544
+ /**
13545
+ * How the approval wait ends early. `r` switches to the Recovery Code; Ctrl-C (a key while the
13546
+ * terminal is in raw mode) and SIGINT, SIGTERM or SIGHUP quit. Either way the wait aborts, so it
13547
+ * cancels its approval server-side before anything exits: a login left pending showed its stale
13548
+ * code in every unlocked tab for ten minutes. `exitCode` is set once the user has quit.
13549
+ */
13550
+ function approvalWaitControls() {
13551
+ const abort = new AbortController();
13552
+ let exitCode = null;
13553
+ return {
13554
+ signal: abort.signal,
13555
+ get exitCode() {
13556
+ return exitCode;
13557
+ },
13558
+ onKey(key) {
13559
+ if (key === "r" || key === "R") abort.abort();
13560
+ if (key === "") {
13561
+ exitCode = SIGNAL_EXIT_CODES.SIGINT;
13562
+ abort.abort();
13563
+ }
13564
+ },
13565
+ onSignal(name) {
13566
+ exitCode = SIGNAL_EXIT_CODES[name];
13567
+ abort.abort();
13568
+ }
13569
+ };
13570
+ }
13571
+ /** A sleep that ends as soon as the signal aborts, so a quit does not wait out the poll interval. */
13572
+ function sleepUnlessAborted(ms, signal) {
13573
+ return new Promise((resolve) => {
13574
+ if (signal.aborted) return resolve();
13575
+ const done = () => {
13576
+ clearTimeout(timer);
13577
+ signal.removeEventListener("abort", done);
13578
+ resolve();
13579
+ };
13580
+ const timer = setTimeout(done, ms);
13581
+ signal.addEventListener("abort", done, { once: true });
13582
+ });
13583
+ }
12750
13584
  async function unlockByDeviceApproval(api, io) {
12751
13585
  const request = await beginDeviceApproval(api);
12752
13586
  const where = io.clientUrl ? `open EtherPK at ${io.clientUrl}` : "open EtherPK in a browser";
12753
13587
  io.say("");
12754
- io.say(`To approve this device, ${where} (any page - it need not be a note) signed in to this account`);
12755
- io.say("with its graphs unlocked. A prompt will show a code; confirm it matches this one:");
13588
+ io.say(`To approve this device, ${where} (any page - it need not be a note), connected to this account`);
13589
+ io.say("with its keys unlocked. A prompt will show a code; confirm it matches this one:");
12756
13590
  io.say("");
12757
13591
  io.say(` ${request.sas}`);
12758
13592
  io.say("");
12759
13593
  io.say("Waiting (up to ten minutes)…");
12760
13594
  const abandon = async () => {
12761
- await api.cancelDeviceApproval(request.id).catch(() => {});
13595
+ await Promise.race([api.cancelDeviceApproval(request.id).catch(() => {}), new Promise((resolve) => setTimeout(resolve, CANCEL_TIMEOUT_MS).unref())]);
12762
13596
  throw new ApprovalAbandoned();
12763
13597
  };
12764
13598
  const deadline = Date.now() + APPROVAL_TIMEOUT_MS;
@@ -12773,12 +13607,12 @@ async function unlockByDeviceApproval(api, io) {
12773
13607
  }
12774
13608
  throw new Error("The approval was not confirmed in time. Run login again.");
12775
13609
  }
12776
- /** Recovery Code → wrap key → open the vault; what comes back is the vault key to cache. */
13610
+ /**
13611
+ * Recovery Code → wrap key → open the vault; what comes back is the vault key to cache. The same
13612
+ * check the browser makes (recovery-unlock.ts): a wrong code is refused as wrong and nothing is cached.
13613
+ */
12777
13614
  async function unlockByRecoveryCode(api, code) {
12778
- const wrapKey = await deriveVaultWrapKey(normalizeRecoveryCode(code));
12779
- const stored = await api.getVault();
12780
- if (!stored) throw new Error("This account has no encryption keys yet; there is nothing for a Recovery Code to open.");
12781
- return (await openVault(fromBase64Url(stored.vault), wrapKey)).vaultKey;
13615
+ return openVaultWithRecoveryCode(api, code);
12782
13616
  }
12783
13617
  //#endregion
12784
13618
  //#region ../client/src/lib/document/publish/selection.ts
@@ -12898,7 +13732,11 @@ function publicDocumentsInNoPublication(documents, publications) {
12898
13732
  }
12899
13733
  //#endregion
12900
13734
  //#region ../client/src/lib/document/publish/host/site-writer.ts
12901
- /** Stale files are removed only from the places the publisher owns. */
13735
+ /**
13736
+ * Stale files are removed only from the places the publisher owns. `etherpk-publish.json` is not
13737
+ * produced but stays owned, so the first publish after the upgrade deletes the copy an older
13738
+ * build left in the folder.
13739
+ */
12902
13740
  function isOwnedPath(path) {
12903
13741
  if (path.startsWith("assets/") || path.startsWith("theme/")) return true;
12904
13742
  if (path.includes("/")) return false;
@@ -13559,6 +14397,50 @@ function archiveHtml(journals) {
13559
14397
  return `<ul class="journal-archive">${journals.map((j) => `<li><time datetime="${escapeHtml$6(j.date ?? "")}">${escapeHtml$6(j.date ?? "")}</time> <a href="${escapeHtml$6(j.url)}">${escapeHtml$6(j.title)}</a>${j.excerpt ? `<p>${escapeHtml$6(j.excerpt)}</p>` : ""}</li>`).join("")}</ul>`;
13560
14398
  }
13561
14399
  //#endregion
14400
+ //#region ../client/src/lib/document/publish/diagram-id.ts
14401
+ /**
14402
+ * Renaming a drawn Mermaid diagram's id, so a published page can carry it.
14403
+ *
14404
+ * Mermaid renders a diagram under the id it is given and scopes everything else to that id: every
14405
+ * rule of its inline `<style>` (`#gk-mermaid-4 .node rect { fill: … }`), its marker definitions
14406
+ * (`gk-mermaid-4_flowchart-pointEnd`) and the `url(#…)` references to them, and its accessible
14407
+ * title and description ids. The published `<svg>` must keep an id its styles match; without one
14408
+ * no rule applies and every node rect takes SVG's default black fill.
14409
+ *
14410
+ * The host's own id is not good enough to keep as it is. It comes from a render counter, so it
14411
+ * changes from one publish to the next (and the browser host reuses the editor's cached drawings),
14412
+ * which would rewrite every page with a diagram on every publish; and one drawing is reused for
14413
+ * every occurrence of the same source, so a diagram shown twice on a page would put one id there
14414
+ * twice. The publisher therefore renames each occurrence to an id of its own choosing.
14415
+ */
14416
+ /** The root element's id, or null. Only the opening tag is read. */
14417
+ function rootId(svg) {
14418
+ const open = /^\s*<svg\b[^>]*>/.exec(svg)?.[0];
14419
+ if (!open) return null;
14420
+ return /\sid="([^"]+)"/.exec(open)?.[1] ?? null;
14421
+ }
14422
+ /** The id the inline styles are scoped to, for a drawing whose root has lost its id. */
14423
+ function styleScope(svg) {
14424
+ const style = /<style\b[^>]*>([\s\S]*?)<\/style>/.exec(svg)?.[1];
14425
+ if (!style) return null;
14426
+ return /#([A-Za-z][\w-]*)(?=[\s{.,>:])/.exec(style)?.[1] ?? null;
14427
+ }
14428
+ function escapeRegExp(text) {
14429
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14430
+ }
14431
+ /**
14432
+ * The drawing with its id, and everything scoped to it, renamed to `id`. `id` must be a valid
14433
+ * CSS identifier that starts with a letter. A drawing with no id anywhere is returned as it is:
14434
+ * nothing in it is scoped, so there is nothing to keep in step.
14435
+ */
14436
+ function withDiagramId(svg, id) {
14437
+ const current = rootId(svg);
14438
+ if (current) return svg.replace(new RegExp(`${escapeRegExp(current)}(?![A-Za-z0-9])`, "g"), id);
14439
+ const scope = styleScope(svg);
14440
+ if (!scope) return svg;
14441
+ return svg.replace(new RegExp(`${escapeRegExp(scope)}(?![A-Za-z0-9])`, "g"), id).replace(/^(\s*<svg\b)/, `$1 id="${id}"`);
14442
+ }
14443
+ //#endregion
13562
14444
  //#region ../client/src/lib/document/code-languages.ts
13563
14445
  /**
13564
14446
  * The grammar for a [[Fenced Code Block]]'s info-string, from the registry the editor nests inside
@@ -14622,18 +15504,32 @@ async function publishPublication(source, publication, env, options = {}) {
14622
15504
  const html = await highlight(fence.lang, fence.code);
14623
15505
  if (html !== null) highlighted.set(key, html);
14624
15506
  }
15507
+ let diagramScope = {
15508
+ prefix: "mermaid",
15509
+ n: 0
15510
+ };
15511
+ const scopeDiagrams = (prefix) => {
15512
+ diagramScope = {
15513
+ prefix: prefix.replace(/[^A-Za-z0-9_-]/g, "-"),
15514
+ n: 0
15515
+ };
15516
+ };
14625
15517
  const renderer = createDocumentRenderer({
14626
15518
  resolve: resolver.resolve,
14627
15519
  assetHref: assetHrefOf,
14628
- mermaidSvg: (src) => mermaidSvg.get(src),
15520
+ mermaidSvg: (src) => {
15521
+ const svg = mermaidSvg.get(src);
15522
+ return svg === void 0 ? void 0 : withDiagramId(svg, `${diagramScope.prefix}_${++diagramScope.n}`);
15523
+ },
14629
15524
  highlighted: (lang, code) => highlighted.get(`${lang}\n${code}`)
14630
15525
  });
14631
15526
  const pages = [];
14632
15527
  let done = 0;
14633
15528
  for (const doc of included) {
14634
15529
  progress("rendering", done++, included.length);
14635
- const rendered = renderer.render(bodyOf(doc));
14636
15530
  const slug = slugs.get(conceptKey$1(doc.concept));
15531
+ scopeDiagrams(`mermaid_${slug}`);
15532
+ const rendered = renderer.render(bodyOf(doc));
14637
15533
  const isHome = doc === homeDoc;
14638
15534
  const page = {
14639
15535
  doc,
@@ -14711,7 +15607,10 @@ async function publishPublication(source, publication, env, options = {}) {
14711
15607
  continue;
14712
15608
  }
14713
15609
  customCss += (customCss ? "\n" : "") + fence.code;
14714
- } else includes.set(slot, renderer.render(body).html);
15610
+ } else {
15611
+ scopeDiagrams(`mermaid__${slot}`);
15612
+ includes.set(slot, renderer.render(body).html);
15613
+ }
14715
15614
  report.includes.push({
14716
15615
  name: slot,
14717
15616
  source: "page",
@@ -15088,9 +15987,10 @@ function createThemeLoader(deps) {
15088
15987
  /** Replace a document's frontmatter block with the one `rewrite` produces, through its live handle. */
15089
15988
  async function rewriteFrontmatter(store, concept, rewrite) {
15090
15989
  await store.whenReady?.(concept);
15990
+ await store.flushDocument?.(concept);
15091
15991
  const handle = store.open(concept);
15092
15992
  const text = handle.getText();
15093
- const next = rewrite(text);
15993
+ const next = withAliasesInAddedBlock(text, rewrite(text), registryAliases(store, concept));
15094
15994
  if (next === text) return false;
15095
15995
  const before = frontmatterSpan(text)?.end ?? 0;
15096
15996
  const after = frontmatterSpan(next)?.end ?? 0;
@@ -15102,6 +16002,11 @@ async function rewriteFrontmatter(store, concept, rewrite) {
15102
16002
  await store.flushDocument?.(concept);
15103
16003
  return true;
15104
16004
  }
16005
+ /** The document's aliases as its store's registry holds them, found by its identity key. */
16006
+ function registryAliases(store, concept) {
16007
+ const key = conceptKey(concept);
16008
+ return store.listDocuments?.().find((entry) => entry.key === key)?.aliases ?? [];
16009
+ }
15105
16010
  /** A publication id from a title: `Docs Site` → `docs-site`. */
15106
16011
  function suggestPublicationId(title) {
15107
16012
  return publishSlug(title);
@@ -15210,7 +16115,6 @@ async function runPublish(publication, deps) {
15210
16115
  code: "documents-unsettled",
15211
16116
  message: `${deps.unsettled.length} document${deps.unsettled.length === 1 ? " has" : "s have"} not finished syncing to this device and ${deps.unsettled.length === 1 ? "was" : "were"} left out: ${deps.unsettled.slice(0, 5).join(", ")}${deps.unsettled.length > 5 ? "…" : ""}. Publish again once sync has caught up.`
15212
16117
  });
15213
- if (report.ok) bundle.set("etherpk-publish.json", `${JSON.stringify(report, null, 2)}\n`);
15214
16118
  return {
15215
16119
  report,
15216
16120
  bundle,
@@ -15355,7 +16259,6 @@ async function openDiagramRenderer(env) {
15355
16259
  const { svg } = await m.render(renderId, text);
15356
16260
  const el = new DOMParser().parseFromString(svg, "text/html").querySelector("svg");
15357
16261
  if (!el) throw new Error("Mermaid produced no diagram.");
15358
- el.removeAttribute("id");
15359
16262
  el.setAttribute("role", "img");
15360
16263
  return el.outerHTML;
15361
16264
  } finally {
@@ -15955,7 +16858,7 @@ function bounded(value, fallback, max) {
15955
16858
  */
15956
16859
  async function settle$2(graph) {
15957
16860
  const result = await graph.settle();
15958
- if (!result.settled) throw new ToolError("not_settled", result.message);
16861
+ if (!result.settled) throw new ToolError(result.code ?? "not_settled", result.message);
15959
16862
  }
15960
16863
  async function listDocuments(graph, args = {}) {
15961
16864
  await graph.store.refresh();
@@ -16195,7 +17098,7 @@ async function setFrontmatter(graph, args) {
16195
17098
  const body = await liveText(graph, identity);
16196
17099
  refuseIfProtected(identity.concept, body);
16197
17100
  const raw = graph.store.openRaw(identity.concept).getText();
16198
- const next = patchedText(raw, args.patch);
17101
+ const next = withAliasesInAddedBlock(raw, patchedText(raw, args.patch), identity.aliases);
16199
17102
  applyBlock(graph, identity.concept, raw, next);
16200
17103
  await settle$2(graph);
16201
17104
  return {
@@ -16669,9 +17572,30 @@ async function graphInfo(graph) {
16669
17572
  function hostOf$1(host) {
16670
17573
  return {
16671
17574
  env: host?.env ?? process.env,
16672
- cmd: host?.cmd ?? "etherpk-mcp"
17575
+ cmd: host?.cmd ?? "etherpk-mcp",
17576
+ via: host?.via ?? "agent"
16673
17577
  };
16674
17578
  }
17579
+ /**
17580
+ * The refusal for an id no publication has, naming the ids there are. An agent is pointed at
17581
+ * its tool; a person at the command line, who has no agent tool to run, at the Publish tab.
17582
+ */
17583
+ function publicationNotFound(id, publications, via) {
17584
+ const where = via === "cli" ? "Settings → Publish in EtherPK lists them." : "list_publications shows them.";
17585
+ return new ToolError("publication_not_found", `No publication has the id "${id}". ${publications.length === 0 ? "This graph defines no publication yet." : `Its publications: ${publications.map((p) => p.id).join(", ")}.`} ${where}`);
17586
+ }
17587
+ /**
17588
+ * The publication with this id, or the refusal a publish would give. The command line asks
17589
+ * before it remembers a publish folder, so a mistyped id is not written down.
17590
+ */
17591
+ async function findPublication(graph, id, host) {
17592
+ const { via } = hostOf$1(host);
17593
+ await graph.store.refresh();
17594
+ const { publications } = summarisePublishing((await graph.publishing.readSource()).source);
17595
+ const found = publications.find((p) => p.id === id.trim());
17596
+ if (!found) throw publicationNotFound(id, publications, via);
17597
+ return found;
17598
+ }
16675
17599
  /** The publication page's mapping and body, through the raw handles: what `publish-service` writes to. */
16676
17600
  function frontmatterStore(graph) {
16677
17601
  return {
@@ -16772,8 +17696,9 @@ async function createPublication(graph, args, host) {
16772
17696
  async function updatePublication(graph, args, host) {
16773
17697
  const { env } = hostOf$1(host);
16774
17698
  await graph.store.refresh();
16775
- const current = summarisePublishing((await graph.publishing.readSource()).source).publications.find((p) => p.id === args.id.trim());
16776
- if (!current) throw new ToolError("publication_not_found", `No publication has the id "${args.id}"; list_publications shows them.`);
17699
+ const all = summarisePublishing((await graph.publishing.readSource()).source).publications;
17700
+ const current = all.find((p) => p.id === args.id.trim());
17701
+ if (!current) throw publicationNotFound(args.id, all, hostOf$1(host).via);
16777
17702
  const changes = args.changes ?? {};
16778
17703
  if (changes.kind !== void 0 && changes.kind !== null && changes.kind !== "docs" && changes.kind !== "blog") throw new ToolError("invalid_argument", "kind must be \"docs\" or \"blog\".");
16779
17704
  if (changes.selection !== void 0 && changes.selection !== null && changes.selection !== "named" && changes.selection !== "all-public") throw new ToolError("invalid_argument", "selection must be \"named\" or \"all-public\".");
@@ -16786,6 +17711,12 @@ async function updatePublication(graph, args, host) {
16786
17711
  const folderOf = await foldersFor(graph, env);
16787
17712
  return { publication: publicationView(after ?? current, folderOf(current.id)) };
16788
17713
  }
17714
+ /** How many items fall under each key. */
17715
+ function countBy(items, keyOf) {
17716
+ const counts = {};
17717
+ for (const item of items) counts[keyOf(item)] = (counts[keyOf(item)] ?? 0) + 1;
17718
+ return counts;
17719
+ }
16789
17720
  /** The first entries of a long list, and how many there were. */
16790
17721
  function head(items, n = 20) {
16791
17722
  return {
@@ -16796,15 +17727,22 @@ function head(items, n = 20) {
16796
17727
  /**
16797
17728
  * Publish one publication into its Publish Folder and report. The folder is the one a person
16798
17729
  * set for this graph and publication on this machine (ADR 0086); a publish with Mermaid needs
16799
- * the browser (ADR 0084). The full report is in the folder as `etherpk-publish.json`.
17730
+ * the browser (ADR 0084).
17731
+ *
17732
+ * The result IS the report, trimmed to counts and first entries: nothing of it is written into
17733
+ * the folder, because a static host serves whatever the folder holds and the report names every
17734
+ * document the site leaves out, protected ones included. The documents left out are counted by
17735
+ * reason rather than listed: that is every document outside the publication, most of a large
17736
+ * graph. The command line prints {@link cliPublishOutput} instead, which names nothing the site
17737
+ * leaves out.
16800
17738
  */
16801
17739
  async function publish(graph, args, host) {
16802
- const { env, cmd } = hostOf$1(host);
17740
+ const { env, cmd, via } = hostOf$1(host);
16803
17741
  await graph.store.refresh();
16804
17742
  const { source, unsettled } = await graph.publishing.readSource();
16805
17743
  const summary = summarisePublishing(source);
16806
17744
  const publication = summary.publications.find((p) => p.id === args.id.trim());
16807
- if (!publication) throw new ToolError("publication_not_found", `No publication has the id "${args.id}"; list_publications shows them.`);
17745
+ if (!publication) throw publicationNotFound(args.id, summary.publications, via);
16808
17746
  const folder = (await foldersFor(graph, env))(publication.id);
16809
17747
  if (!folder) {
16810
17748
  const where = graph.backend.kind === "folder" ? `--folder "${graph.backend.path}"` : `--graph "${graph.name}"`;
@@ -16851,20 +17789,47 @@ async function publish(graph, args, host) {
16851
17789
  },
16852
17790
  errors: run.report.errors,
16853
17791
  warnings: run.report.warnings,
16854
- missingLinks: head(run.report.missingLinks),
17792
+ missingLinks: {
17793
+ ...head(run.report.missingLinks),
17794
+ byStatus: countBy(run.report.missingLinks, (link) => link.status)
17795
+ },
16855
17796
  assets: {
16856
17797
  copied: run.report.assets.copied.length,
16857
17798
  missing: run.report.assets.missing
16858
17799
  },
16859
17800
  collisions: run.report.collisions,
16860
17801
  publicInNoPublication: run.report.publicInNoPublication,
16861
- ...unsettled.length > 0 ? { unsettled } : {},
16862
- report: run.report.ok ? `${folder}/etherpk-publish.json` : null
17802
+ ...unsettled.length > 0 ? { unsettled } : {}
16863
17803
  };
16864
17804
  } finally {
16865
17805
  await renderer?.dispose();
16866
17806
  }
16867
17807
  }
17808
+ /**
17809
+ * What `etherpk-mcp publish` prints: the tool's result with every name the site leaves out
17810
+ * reduced to a count. A scheduled publish's log can be as public as the site (a public CI run),
17811
+ * so links are counted by status (a "private" status says a hidden page exists), documents not
17812
+ * yet synced and public documents in no publication are counted, and warnings keep their codes
17813
+ * without the sentences that name pages. What was published, and the errors that stopped a
17814
+ * publish, are printed as they are: the site shows the one, and the other is what to fix.
17815
+ */
17816
+ function cliPublishOutput(result) {
17817
+ const { unsettled, ...rest } = result;
17818
+ return {
17819
+ ...rest,
17820
+ missingLinks: {
17821
+ total: result.missingLinks.total,
17822
+ byStatus: result.missingLinks.byStatus
17823
+ },
17824
+ warnings: {
17825
+ total: result.warnings.length,
17826
+ byCode: countBy(result.warnings, (warning) => warning.code)
17827
+ },
17828
+ publicInNoPublication: result.publicInNoPublication.length,
17829
+ ...unsettled ? { unsettled: unsettled.length } : {},
17830
+ note: "Documents the site leaves out are counted here, not named. Settings → Publish in EtherPK shows the full report."
17831
+ };
17832
+ }
16868
17833
  /** The publishing half of `graph_info`: each publication with its folder here, and whether diagrams can be drawn. */
16869
17834
  async function publishingInfo(graph, host) {
16870
17835
  const { env, cmd } = hostOf$1(host);
@@ -17480,13 +18445,22 @@ function failed(error) {
17480
18445
  isError: true
17481
18446
  };
17482
18447
  }
17483
- async function run(work) {
18448
+ async function runTool(work) {
17484
18449
  try {
17485
18450
  return ok(await work());
17486
18451
  } catch (error) {
17487
18452
  return failed(error);
17488
18453
  }
17489
18454
  }
18455
+ /**
18456
+ * The refusal every tool gives once the Sync Server has ended access, instead of answering from
18457
+ * a cache that can no longer be trusted or saved.
18458
+ */
18459
+ function accessEndedError(graph, cmd) {
18460
+ const loss = graph.accessLoss();
18461
+ if (!loss) return null;
18462
+ return loss.kind === "membership" ? new ToolError("access_removed", `This account no longer has access to the graph: it left, was removed by the owner, or the graph was deleted. Run ${cmd} graphs to see the graphs it can reach.`) : new ToolError("token_revoked", `The Sync Server no longer accepts this computer's access token: it was revoked or has expired, or the account's password was reset. Run ${cmd} login again.`);
18463
+ }
17490
18464
  var concept = z.string().min(1).describe("A page title, one of its aliases, a journal day as YYYY-MM-DD, or \"today\".");
17491
18465
  var offset = z.number().int().nonnegative().optional().describe("Skip this many results (paging).");
17492
18466
  /** A frontmatter value as JSON carries it; the writer turns it into YAML. */
@@ -17499,6 +18473,11 @@ var frontmatterValue = z.union([
17499
18473
  z.record(z.string(), z.unknown())
17500
18474
  ]);
17501
18475
  function createMcpServer(graph, info) {
18476
+ const run = (work) => runTool(() => {
18477
+ const ended = accessEndedError(graph, info.cmd ?? "etherpk-mcp");
18478
+ if (ended) throw ended;
18479
+ return work();
18480
+ });
17502
18481
  const server = new McpServer({
17503
18482
  name: "etherpk",
17504
18483
  version: info.version
@@ -17731,7 +18710,7 @@ function createMcpServer(graph, info) {
17731
18710
  }, async (args) => run(() => updatePublication(graph, args, host)));
17732
18711
  server.registerTool("publish", {
17733
18712
  title: "Publish",
17734
- description: "Render a publication to its publish folder on this machine and return the report: what was included and why documents were left out, missing links, assets, warnings. The folder is the one the user set with \"etherpk-mcp publish --publication <id> --out <dir>\" (error \"no_publish_folder\" until then; the tool never chooses a folder). Pages with Mermaid diagrams need the browser from \"diagrams setup\" (error \"chromium_unavailable\"). The full report is written to the folder as etherpk-publish.json. Publishing writes files; it does not deploy them.",
18713
+ description: "Render a publication to its publish folder on this machine and return the report: what was included and why documents were left out, missing links, assets, warnings. The folder is the one the user set with \"etherpk-mcp publish --publication <id> --out <dir>\" (error \"no_publish_folder\" until then; the tool never chooses a folder). Pages with Mermaid diagrams need the browser from \"diagrams setup\" (error \"chromium_unavailable\"). The report is this result, trimmed to counts and first entries; nothing of it is written into the folder, so the site never names the documents it leaves out. Publishing writes files; it does not deploy them.",
17735
18714
  inputSchema: { id: z.string().min(1) }
17736
18715
  }, async (args) => run(() => publish(graph, args, host)));
17737
18716
  server.registerTool("list_themes", {
@@ -18013,7 +18992,7 @@ function bindServeLifetime(deps) {
18013
18992
  * etherpk-mcp diagrams setup | status
18014
18993
  *
18015
18994
  * One config file holds a login per Sync Server (ADR 0075). `--sync-server` names the one a
18016
- * command means and may be left out while only one is signed in.
18995
+ * command means and may be left out while there is only one login.
18017
18996
  *
18018
18997
  * `serve` speaks MCP over stdio, so everything for the human goes to stderr; stdout belongs
18019
18998
  * to the agent. `login` and `graphs` are interactive and print to stdout.
@@ -18032,11 +19011,11 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
18032
19011
  Sign this machine in as a device of your account. Prompts for a Personal Access
18033
19012
  Token (an account-wide one, from the Sync Server portal at <url>/account/tokens)
18034
19013
  unless --pat or ETHERPK_PAT is given, then unlocks your keys by Device Approval:
18035
- open EtherPK in a browser signed in to the account with its graphs unlocked and
19014
+ open EtherPK in a browser connected to the account with its keys unlocked and
18036
19015
  confirm the code shown. Press r while waiting, or pass --recovery-code, to type
18037
19016
  your Recovery Code instead (or ETHERPK_RECOVERY_CODE, for a scripted setup).
18038
19017
  ${CMD} graphs [--sync-server <url>]
18039
- List the synced graphs each signed-in account can reach, by name and id.
19018
+ List the synced graphs each logged-in account can reach, by name and id.
18040
19019
  ${CMD} serve --graph <id or name> [--sync-server <url>] [--no-semantic]
18041
19020
  Serve one synced graph to an agent over stdio. For Claude Code:
18042
19021
  claude mcp add etherpk -- npx @appsoftwareltd/etherpk-mcp serve --sync-server <url> --graph <id>
@@ -18074,8 +19053,8 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
18074
19053
  ${CMD} diagrams status
18075
19054
  Which browser a publish would use, if any.
18076
19055
 
18077
- This machine can be signed in to several Sync Servers at once; --sync-server says which one
18078
- a command means, and can be left out while only one is signed in. The config file is
19056
+ This machine can hold logins for several Sync Servers at once; --sync-server says which one
19057
+ a command means, and can be left out while there is only one. The config file is
18079
19058
  ${defaultConfigPath()} (override with
18080
19059
  ETHERPK_MCP_CONFIG); cached graphs live under ~/.cache/etherpk/mcp (override with
18081
19060
  ETHERPK_MCP_CACHE_DIR).
@@ -18085,8 +19064,12 @@ function fail(message) {
18085
19064
  console.error(message);
18086
19065
  process.exit(1);
18087
19066
  }
18088
- async function ask(question, { secret = false } = {}) {
18089
- if (!process.stdin.isTTY) fail(`${question} - no terminal to ask on; pass it as an option.`);
19067
+ /**
19068
+ * Ask on the terminal; `hint` says how to give the answer when there is none (a script, or
19069
+ * an agent starting the process), since each question has its own option or variable.
19070
+ */
19071
+ async function ask(question, { secret = false, hint }) {
19072
+ if (!process.stdin.isTTY) fail(`${question.replace(/:\s*$/, "")}: no terminal to ask on; ${hint}.`);
18090
19073
  if (!secret) {
18091
19074
  const rl = createInterface({
18092
19075
  input: process.stdin,
@@ -18134,24 +19117,30 @@ function requireServer(config, wanted) {
18134
19117
  if (selection.ok) return selection.credentials;
18135
19118
  switch (selection.reason) {
18136
19119
  case "none": return fail(`Not logged in on this machine. Run: ${CMD} login --sync-server <url>`);
18137
- case "unknown": return fail(`Not logged in to ${selection.syncServer}. Signed in to: ${selection.known.join(", ") || "(none)"}. Run: ${CMD} login --sync-server ${selection.syncServer}`);
18138
- case "ambiguous": return fail(`Signed in to more than one Sync Server here: ${selection.known.join(", ")}. Say which with --sync-server <url>.`);
19120
+ case "unknown": return fail(`Not logged in to ${selection.syncServer}. Logged in to: ${selection.known.join(", ") || "(none)"}. Run: ${CMD} login --sync-server ${selection.syncServer}`);
19121
+ case "ambiguous": return fail(`Logged in to more than one Sync Server here: ${selection.known.join(", ")}. Say which with --sync-server <url>.`);
18139
19122
  }
18140
19123
  }
18141
19124
  async function login(args) {
18142
19125
  const path = defaultConfigPath();
18143
19126
  const config = await readConfig(path) ?? emptyConfig();
18144
19127
  const known = Object.keys(config.servers);
18145
- const syncServer = normaliseSyncServer(args["sync-server"] ?? (known.length === 1 ? known[0] : await ask("Sync Server URL: ")));
19128
+ const syncServer = normaliseSyncServer(args["sync-server"] ?? (known.length === 1 ? known[0] : await ask("Sync Server URL: ", { hint: "pass --sync-server <url>" })));
18146
19129
  if (!/^https?:\/\//.test(syncServer)) fail("The Sync Server must be an http(s) URL.");
18147
- const pat = args.pat ?? process.env.ETHERPK_PAT ?? await ask(`Personal Access Token (account-wide, from ${syncServer}/account/tokens): `, { secret: true });
19130
+ const pat = args.pat ?? process.env.ETHERPK_PAT ?? await ask(`Personal Access Token (account-wide, from ${syncServer}/account/tokens): `, {
19131
+ secret: true,
19132
+ hint: "set ETHERPK_PAT or pass --pat <token>"
19133
+ });
18148
19134
  if (!pat) fail("A Personal Access Token is required.");
18149
19135
  const account = await connectAccount({
18150
19136
  syncServer,
18151
19137
  pat
18152
19138
  });
18153
- console.log(`Signed in to ${syncServer} as ${account.principal.email ?? account.principal.name ?? account.principal.id}.`);
18154
- const byRecoveryCode = async () => unlockByRecoveryCode(account.api, process.env.ETHERPK_RECOVERY_CODE ?? await ask("Recovery Code: ", { secret: true }));
19139
+ console.log(`Connected to ${syncServer} as ${account.principal.email ?? account.principal.name ?? account.principal.id}.`);
19140
+ const byRecoveryCode = async () => unlockByRecoveryCode(account.api, process.env.ETHERPK_RECOVERY_CODE ?? await ask("Recovery Code: ", {
19141
+ secret: true,
19142
+ hint: "set ETHERPK_RECOVERY_CODE"
19143
+ }));
18155
19144
  const vaultKey = args["recovery-code"] ? await byRecoveryCode() : await approveOrFallBack(account, byRecoveryCode);
18156
19145
  config.servers[syncServer] = {
18157
19146
  pat,
@@ -18160,7 +19149,7 @@ async function login(args) {
18160
19149
  await writeConfig(path, config);
18161
19150
  console.log(`Keys unlocked and cached in ${path} (owner-only). Anyone who can read your files on this machine can read this account, as with a signed-in browser.`);
18162
19151
  const others = Object.keys(config.servers).filter((server) => server !== syncServer);
18163
- if (others.length > 0) console.log(`Also signed in to ${others.join(", ")}; commands now need --sync-server <url> to say which.`);
19152
+ if (others.length > 0) console.log(`Also logged in to ${others.join(", ")}; commands now need --sync-server <url> to say which.`);
18164
19153
  await listGraphs({
18165
19154
  syncServer,
18166
19155
  pat,
@@ -18174,23 +19163,23 @@ async function login(args) {
18174
19163
  * code instead; without a terminal the wait runs to its outcome.
18175
19164
  */
18176
19165
  async function approveOrFallBack(account, byRecoveryCode) {
18177
- const abort = new AbortController();
19166
+ const controls = approvalWaitControls();
18178
19167
  const io = {
18179
19168
  say: (line) => console.log(line),
18180
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
18181
- signal: abort.signal,
19169
+ sleep: (ms) => sleepUnlessAborted(ms, controls.signal),
19170
+ signal: controls.signal,
18182
19171
  clientUrl: account.clientUrl
18183
19172
  };
18184
19173
  const stdin = process.stdin;
18185
19174
  const interactive = stdin.isTTY === true;
18186
- const onKey = (chunk) => {
18187
- const key = chunk.toString("utf8");
18188
- if (key === "r" || key === "R") abort.abort();
18189
- if (key === "") {
18190
- console.log("");
18191
- process.exit(130);
18192
- }
18193
- };
19175
+ const onKey = (chunk) => controls.onKey(chunk.toString("utf8"));
19176
+ const quitSignals = [
19177
+ "SIGINT",
19178
+ "SIGTERM",
19179
+ "SIGHUP"
19180
+ ];
19181
+ const onSignal = (name) => controls.onSignal(name);
19182
+ for (const name of quitSignals) process.once(name, onSignal);
18194
19183
  if (interactive) {
18195
19184
  console.log("(Press r to type your Recovery Code instead.)");
18196
19185
  stdin.setRawMode(true);
@@ -18204,12 +19193,17 @@ async function approveOrFallBack(account, byRecoveryCode) {
18204
19193
  if (!(error instanceof ApprovalAbandoned)) throw error;
18205
19194
  abandoned = true;
18206
19195
  } finally {
19196
+ for (const name of quitSignals) process.off(name, onSignal);
18207
19197
  if (interactive) {
18208
19198
  stdin.off("data", onKey);
18209
19199
  stdin.setRawMode(false);
18210
19200
  stdin.pause();
18211
19201
  }
18212
19202
  }
19203
+ if (controls.exitCode !== null) {
19204
+ console.log("");
19205
+ process.exit(controls.exitCode);
19206
+ }
18213
19207
  if (!abandoned) throw new Error("unreachable");
18214
19208
  console.log("Approval cancelled; unlocking with your Recovery Code instead.");
18215
19209
  return byRecoveryCode();
@@ -18326,7 +19320,13 @@ async function publishCommand(args) {
18326
19320
  "no-semantic": true
18327
19321
  };
18328
19322
  const { graph, graphName } = folder ? await openFolderForServe(folder, quiet) : await openSyncedForServe(wanted, quiet);
19323
+ const host = {
19324
+ env: process.env,
19325
+ cmd: CMD,
19326
+ via: "cli"
19327
+ };
18329
19328
  try {
19329
+ await findPublication(graph, publication, host);
18330
19330
  const configPath = defaultPublishFoldersPath(process.env);
18331
19331
  const key = publishGraphKey(graph.backend, graph.graphId);
18332
19332
  if (args.out?.trim()) {
@@ -18334,11 +19334,8 @@ async function publishCommand(args) {
18334
19334
  await writePublishFolders(configPath, withPublishFolder(await readPublishFolders(configPath), key, publication, out));
18335
19335
  console.error(`etherpk-mcp: publish folder for "${publication}" of "${graphName}" set to ${out} (remembered in ${configPath}).`);
18336
19336
  } else if (!publishFolderOf(await readPublishFolders(configPath), key, publication)) fail(`No publish folder is set for "${publication}" of "${graphName}" on this machine. Pass --out <dir> once; it is remembered.`);
18337
- const result = await publish(graph, { id: publication }, {
18338
- env: process.env,
18339
- cmd: CMD
18340
- });
18341
- console.log(JSON.stringify(result, null, 2));
19337
+ const result = await publish(graph, { id: publication }, host);
19338
+ console.log(JSON.stringify(cliPublishOutput(result), null, 2));
18342
19339
  if (!result.ok) process.exitCode = 1;
18343
19340
  } catch (error) {
18344
19341
  if (error instanceof ToolError) fail(`etherpk-mcp: ${error.message}`);
@@ -18420,6 +19417,11 @@ async function openSyncedForServe(wanted, args) {
18420
19417
  const account = await connectAccount(login);
18421
19418
  const vault = await openAccountVault(account.api, fromBase64Url(login.vaultKey));
18422
19419
  const graphs = await account.api.listGraphs();
19420
+ const swept = await removeUnlistedGraphCaches(process.env, account.serverBaseUrl, account.principal.id, graphs.map((graph) => graph.id)).catch((error) => {
19421
+ console.error(`etherpk-mcp: could not tidy the cache of graphs this server no longer lists: ${error instanceof Error ? error.message : String(error)}`);
19422
+ return [];
19423
+ });
19424
+ if (swept.length > 0) console.error(`etherpk-mcp: removed this computer's copy of ${swept.length === 1 ? "a graph" : `${swept.length} graphs`} ${account.serverBaseUrl} no longer lists for you: ${swept.join(", ")}.`);
18423
19425
  let graphId = graphs.find((graph) => graph.id === wanted)?.id;
18424
19426
  let graphName = null;
18425
19427
  if (!graphId) {
@@ -18431,6 +19433,8 @@ async function openSyncedForServe(wanted, args) {
18431
19433
  }
18432
19434
  if (!graphId) fail(`No synced graph on ${login.syncServer} is named or identified by "${wanted}". Run: ${CMD} graphs`);
18433
19435
  const { record, keyring } = resolveGraphById(graphs, vault, graphId);
19436
+ const persistDir = graphCacheDir(process.env, account.serverBaseUrl, graphId);
19437
+ await stampGraphCacheOwner(persistDir, account.principal.id);
18434
19438
  console.error(`etherpk-mcp: opening graph ${graphId} on ${account.serverBaseUrl}…`);
18435
19439
  const graph = await openHeadlessGraph({
18436
19440
  graphId,
@@ -18440,11 +19444,12 @@ async function openSyncedForServe(wanted, args) {
18440
19444
  token: account.tokenFor(graphId),
18441
19445
  presenceName: `Agent on ${hostname()}`,
18442
19446
  readyTimeoutMs: 2e4,
18443
- persistDir: graphCacheDir(process.env, account.serverBaseUrl, graphId),
19447
+ persistDir,
18444
19448
  assets: { baseUrl: account.serverBaseUrl },
18445
19449
  embeddingModel: embeddingModelFor(args),
18446
19450
  onSemanticProgress: reportSemanticProgress,
18447
19451
  onError: (error) => console.error(`etherpk-mcp: ${error.message}`),
19452
+ onAccessLost: (loss) => console.error(loss.kind === "membership" ? `etherpk-mcp: ${account.serverBaseUrl} ended this account's access to graph ${graphId}: it left, was removed, or the graph was deleted. Stopped syncing. Run: ${CMD} graphs` : `etherpk-mcp: the token for ${account.serverBaseUrl} was revoked or is no longer valid. Stopped syncing. Run: ${CMD} login --sync-server ${login.syncServer}`),
18448
19453
  publishName: createGraphNamePublisher({
18449
19454
  api: account.api,
18450
19455
  keyring,