@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 +1152 -147
- package/dist/main.js.map +1 -1
- package/package.json +3 -3
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.
|
|
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
|
-
|
|
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(
|
|
678
|
-
|
|
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
|
-
|
|
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 =
|
|
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 ===
|
|
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
|
-
/**
|
|
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)
|
|
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})
|
|
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.
|
|
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
|
-
/**
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
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(
|
|
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")
|
|
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)
|
|
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.
|
|
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
|
-
|
|
6845
|
-
|
|
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
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
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
|
-
|
|
6885
|
-
if (!disposed && !protocolMismatch) setTimeout(connect, RECONNECT_MS);
|
|
7394
|
+
backOff();
|
|
6886
7395
|
});
|
|
6887
|
-
}, () => {
|
|
6888
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
11682
|
+
const held = [];
|
|
11683
|
+
const release = () => graph.retireDocs(held);
|
|
11037
11684
|
try {
|
|
11038
|
-
for (let start = 0; start < unique.length; 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
|
-
|
|
11099
|
-
|
|
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
|
-
|
|
11534
|
-
|
|
11535
|
-
|
|
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
|
|
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
|
-
*
|
|
11964
|
-
*
|
|
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
|
|
12684
|
+
async function fetchAssetBytes(ref) {
|
|
11967
12685
|
const assetId = assetIdFromRef(ref);
|
|
11968
12686
|
if (!assetId) return null;
|
|
11969
|
-
const
|
|
11970
|
-
|
|
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
|
|
11982
|
-
if (!chunkRes.ok)
|
|
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:
|
|
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
|
|
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)
|
|
12755
|
-
io.say("with its
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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) =>
|
|
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
|
|
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
|
|
16776
|
-
|
|
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).
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
18078
|
-
a command means, and can be left out while
|
|
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
|
-
|
|
18089
|
-
|
|
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}.
|
|
18138
|
-
case "ambiguous": return fail(`
|
|
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): `, {
|
|
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(`
|
|
18154
|
-
const byRecoveryCode = async () => unlockByRecoveryCode(account.api, process.env.ETHERPK_RECOVERY_CODE ?? await ask("Recovery Code: ", {
|
|
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
|
|
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
|
|
19166
|
+
const controls = approvalWaitControls();
|
|
18178
19167
|
const io = {
|
|
18179
19168
|
say: (line) => console.log(line),
|
|
18180
|
-
sleep: (ms) =>
|
|
18181
|
-
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
|
-
|
|
18188
|
-
|
|
18189
|
-
|
|
18190
|
-
|
|
18191
|
-
|
|
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
|
-
|
|
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
|
|
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,
|