mainflow 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2862 @@
1
+ import { $ as nowMinute, B as ROUTES, F as renumberFiles, H as applyOperation, I as renumberPath, L as renumberText, N as applyOffline, P as renumberRequest, R as scopeOfPath, V as requestTime, at as ConflictError, ct as contentHash, ot as DATA_DIR, st as WriteError, tt as nodeStorage, z as AUTHORIZATION } from "./host-BJnrv8xF.js";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
7
+ import { parse as parse$1 } from "yaml";
8
+ import { homedir } from "node:os";
9
+ import { createServer, request } from "node:http";
10
+ import { request as request$1 } from "node:https";
11
+ import { EventEmitter } from "node:events";
12
+ import { appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
13
+ import { diff3Merge } from "node-diff3";
14
+ //#region ../sync/dist/api.js
15
+ /**
16
+ * The server could not be reached, or the connection broke. `sent` says whether the request may have reached the
17
+ * server: a refused connection did not, a reset or timeout after sending may have, so its result is unknown.
18
+ */
19
+ var Unreachable = class extends Error {
20
+ sent;
21
+ constructor(message, sent) {
22
+ super(message);
23
+ this.sent = sent;
24
+ }
25
+ };
26
+ /** The server refused before the hub (401, 400, 404) or failed (500). */
27
+ var ServerError = class extends Error {
28
+ status;
29
+ constructor(message, status) {
30
+ super(message);
31
+ this.status = status;
32
+ }
33
+ };
34
+ /** Error codes of a connection that never carried the request. */
35
+ const NOT_SENT = /* @__PURE__ */ new Set([
36
+ "ECONNREFUSED",
37
+ "ENOTFOUND",
38
+ "EAI_AGAIN",
39
+ "EHOSTUNREACH",
40
+ "ENETUNREACH",
41
+ "UND_ERR_CONNECT_TIMEOUT",
42
+ "ECONNECT_TIMEOUT"
43
+ ]);
44
+ /** Error codes of a pooled connection the server had already closed. */
45
+ const STALE = /* @__PURE__ */ new Set([
46
+ "UND_ERR_SOCKET",
47
+ "ECONNRESET",
48
+ "EPIPE"
49
+ ]);
50
+ const causeCode = (error) => {
51
+ for (let cause = error; cause; cause = cause.cause) if (typeof cause.code === "string") return cause.code;
52
+ };
53
+ var ServerApi = class {
54
+ settings;
55
+ timeoutMs;
56
+ constructor(settings, timeoutMs = 3e4) {
57
+ this.settings = settings;
58
+ this.timeoutMs = timeoutMs;
59
+ }
60
+ get headers() {
61
+ return {
62
+ [AUTHORIZATION]: `Bearer ${this.settings.token}`,
63
+ "content-type": "application/json"
64
+ };
65
+ }
66
+ async call(method, route, body) {
67
+ const url = `${this.settings.url}${route}`;
68
+ let answer;
69
+ try {
70
+ answer = method === "GET" ? await this.fetchGet(url) : await this.freshRequest(method, url, JSON.stringify(body));
71
+ } catch (error) {
72
+ const code = causeCode(error);
73
+ const detail = [code, error.cause?.message ?? error.message].filter(Boolean).join(": ");
74
+ throw new Unreachable(`cannot reach the sync server at ${this.settings.url}${detail ? ` (${detail})` : ""}`, !(code && NOT_SENT.has(code)));
75
+ }
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(answer.text);
79
+ } catch {
80
+ throw new Unreachable(`the sync server answered ${answer.status} without a readable body`, true);
81
+ }
82
+ if (answer.status >= 500) throw new Unreachable(`the sync server failed (${answer.status})`, true);
83
+ const refused = parsed;
84
+ if (answer.status === 401 || answer.status === 404 || refused.reason === "malformed") throw new ServerError(`the sync server refused: ${refused.message ?? answer.status}`, answer.status);
85
+ return {
86
+ status: answer.status,
87
+ body: parsed
88
+ };
89
+ }
90
+ /** A read may go on a pooled connection: one the server closed meanwhile fails at once, and the read is repeated. */
91
+ async fetchGet(url) {
92
+ for (let attempt = 0;; attempt++) try {
93
+ const response = await fetch(url, {
94
+ headers: this.headers,
95
+ signal: AbortSignal.timeout(this.timeoutMs)
96
+ });
97
+ return {
98
+ status: response.status,
99
+ text: await response.text()
100
+ };
101
+ } catch (error) {
102
+ if (attempt > 0 || !STALE.has(causeCode(error) ?? "")) throw error;
103
+ }
104
+ }
105
+ /**
106
+ * A write goes on a connection of its own, never on a pooled one. The server may have closed an idle pooled
107
+ * connection with the close still on its way; a request sent on it is lost, and the client could not tell that
108
+ * from a request lost after the server applied it.
109
+ */
110
+ freshRequest(method, url, body) {
111
+ const send = url.startsWith("https:") ? request$1 : request;
112
+ return new Promise((resolve, reject) => {
113
+ const req = send(url, {
114
+ method,
115
+ agent: false,
116
+ headers: {
117
+ ...this.headers,
118
+ "content-length": Buffer.byteLength(body)
119
+ },
120
+ timeout: this.timeoutMs
121
+ }, (res) => {
122
+ const chunks = [];
123
+ res.on("data", (chunk) => chunks.push(chunk));
124
+ res.on("end", () => resolve({
125
+ status: res.statusCode ?? 0,
126
+ text: Buffer.concat(chunks).toString("utf8")
127
+ }));
128
+ res.on("error", reject);
129
+ });
130
+ req.on("timeout", () => req.destroy(Object.assign(/* @__PURE__ */ new Error("no answer in time"), { code: req.socket?.connecting === false ? "ETIMEDOUT" : "ECONNECT_TIMEOUT" })));
131
+ req.on("error", reject);
132
+ req.end(body);
133
+ });
134
+ }
135
+ /** Send one request; the response is the hub's answer, applied or refused. */
136
+ async send(request) {
137
+ return (await this.call("POST", ROUTES.request(this.settings.hub), request)).body;
138
+ }
139
+ /** Every file of the hub, as of the hub's `seq`. */
140
+ async snapshot() {
141
+ const { body } = await this.call("GET", `${ROUTES.snapshot(this.settings.hub)}?protocol=2`);
142
+ if (!body.ok) throw new ServerError(`the sync server refused the snapshot: ${body.message}`, body.status);
143
+ return {
144
+ seq: body.seq,
145
+ files: body.files
146
+ };
147
+ }
148
+ /** Upload a whole hub; refused with 409 when the server already holds one and `replace` is not set. */
149
+ async upload(files, dirs, replace = false) {
150
+ const { body } = await this.call("PUT", ROUTES.snapshot(this.settings.hub), {
151
+ protocol: 2,
152
+ files,
153
+ dirs,
154
+ replace
155
+ });
156
+ return body;
157
+ }
158
+ /** Open the hub's broadcast connection. */
159
+ socket() {
160
+ const url = `${this.settings.url.replace(/^http/, "ws")}${ROUTES.socket(this.settings.hub)}?protocol=2`;
161
+ return new WebSocket(url, { headers: { [AUTHORIZATION]: `Bearer ${this.settings.token}` } });
162
+ }
163
+ };
164
+ //#endregion
165
+ //#region ../sync/dist/lines.js
166
+ const split = (text) => text.split("\n");
167
+ /**
168
+ * Which lines of `a` and of `b` belong to a longest common subsequence of the two, after the common prefix
169
+ * and suffix, which always belong to it. `keptA[i]` is true when line i of a is in b, and likewise `keptB`.
170
+ */
171
+ function alignLines(a, b) {
172
+ const keptA = Array.from({ length: a.length }, () => false);
173
+ const keptB = Array.from({ length: b.length }, () => false);
174
+ let start = 0;
175
+ while (start < a.length && start < b.length && a[start] === b[start]) {
176
+ keptA[start] = keptB[start] = true;
177
+ start++;
178
+ }
179
+ let endA = a.length;
180
+ let endB = b.length;
181
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) keptA[--endA] = keptB[--endB] = true;
182
+ const n = endA - start;
183
+ const m = endB - start;
184
+ if (n === 0 || m === 0) return {
185
+ keptA,
186
+ keptB
187
+ };
188
+ const width = m + 1;
189
+ const lengths = new Uint32Array((n + 1) * width);
190
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lengths[i * width + j] = a[start + i] === b[start + j] ? lengths[(i + 1) * width + j + 1] + 1 : Math.max(lengths[(i + 1) * width + j], lengths[i * width + j + 1]);
191
+ for (let i = 0, j = 0; i < n && j < m;) if (a[start + i] === b[start + j]) {
192
+ keptA[start + i] = keptB[start + j] = true;
193
+ i++;
194
+ j++;
195
+ } else if (lengths[(i + 1) * width + j] >= lengths[i * width + j + 1]) i++;
196
+ else j++;
197
+ return {
198
+ keptA,
199
+ keptB
200
+ };
201
+ }
202
+ /** How many lines differ between two texts: the lines only in a plus the lines only in b. */
203
+ function lineDistance(a, b) {
204
+ if (a === b) return 0;
205
+ const { keptA, keptB } = alignLines(split(a), split(b));
206
+ return keptA.filter((kept) => !kept).length + keptB.filter((kept) => !kept).length;
207
+ }
208
+ //#endregion
209
+ //#region ../sync/dist/merge.js
210
+ /**
211
+ * Merge `incoming` into `current`, both descended from `base`. Where both sides only added lines at the same
212
+ * place, both are kept, the current side first, as git's union merge does; any other overlap fails.
213
+ */
214
+ function mergeThreeWay(current, base, incoming) {
215
+ const regions = diff3Merge(current.split("\n"), base.split("\n"), incoming.split("\n"), { excludeFalseConflicts: true });
216
+ const lines = [];
217
+ for (const region of regions) {
218
+ if (region.ok) {
219
+ lines.push(...region.ok);
220
+ continue;
221
+ }
222
+ const conflict = region.conflict;
223
+ if (conflict.o.length > 0) return { ok: false };
224
+ lines.push(...conflict.a, ...conflict.b);
225
+ }
226
+ return {
227
+ ok: true,
228
+ text: lines.join("\n")
229
+ };
230
+ }
231
+ //#endregion
232
+ //#region ../sync/dist/paths.js
233
+ /** The sync client's own directory, inside local/ so it is neither synced nor committed. */
234
+ const SYNC_DIR = "local/sync";
235
+ /** The settings a machine keeps instead of the environment variables: { url, token }. Never synced or committed. */
236
+ const SETTINGS_FILE = "local/sync.json";
237
+ const syncPaths = (root) => {
238
+ const data = join(root, DATA_DIR);
239
+ const own = join(data, SYNC_DIR);
240
+ return {
241
+ data,
242
+ own,
243
+ tmp: join(own, "tmp"),
244
+ conflicts: join(own, "conflicts"),
245
+ known: join(own, "known.json"),
246
+ queue: join(own, "queue.json"),
247
+ notices: join(own, "notices.jsonl"),
248
+ daemon: join(own, "daemon.json"),
249
+ /** The sync client a mainflow write last started in the background: { pid, at } */
250
+ started: join(own, "started.json"),
251
+ /** What a sync client started in the background prints */
252
+ log: join(own, "sync.log")
253
+ };
254
+ };
255
+ /** Names programs leave behind while writing: editor swap and backup files, temporary files, Finder's. */
256
+ const TRANSIENT = [
257
+ /\.tmp$/,
258
+ /\.sw[a-p]$/,
259
+ /\.swx$/,
260
+ /~$/,
261
+ /^\.#/,
262
+ /^4913$/,
263
+ /\.crswap$/,
264
+ /^\.DS_Store$/
265
+ ];
266
+ /** Whether a file at `path` (relative to .mainflow/) is synced. */
267
+ function isSynced(path) {
268
+ const parts = path.split("/");
269
+ if (parts.some((part) => part === "" || part === "." || part === "..")) return false;
270
+ if (parts[0] === "local" && !(parts[1] === "sessions" && parts.length === 3)) return false;
271
+ const name = parts.at(-1);
272
+ return !TRANSIENT.some((pattern) => pattern.test(name));
273
+ }
274
+ /** Whether the directory at `path` may hold synced files, so a scan walks into it. */
275
+ const mayHoldSynced = (path) => path !== "local" ? !path.startsWith("local/") || path === "local/sessions" : true;
276
+ //#endregion
277
+ //#region ../sync/dist/state.js
278
+ /** A new entry's id. */
279
+ const entryId = () => randomBytes(8).toString("hex");
280
+ const entryPaths = (entry) => entry.kind === "op" ? Object.keys(entry.written) : [entry.path];
281
+ /** A local minute without an offset, the `now` of protocol 1 */
282
+ const LOCAL_MINUTE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/;
283
+ /**
284
+ * A queued operation on the current protocol. One a command of mainflow before the upgrade queued is sent on the
285
+ * current one: protocol 1 differs only in `now`, a minute in this machine's time zone without its offset, which gets
286
+ * the offset this machine has at that minute. The outbox is this machine's, so the time zone is the one it was
287
+ * written in. Any other protocol is sent as it is, and a server on another version refuses it with both versions.
288
+ *
289
+ * Around a daylight saving change the minute is read as Date reads a local time. A minute that occurs twice, in the
290
+ * hour an autumn change repeats, always gets the earlier offset, the one before the change; the minute itself is
291
+ * kept, and the server writes only the minute until it uses the offset, so nothing written differs. A minute in the
292
+ * hour a spring change skips would move an hour forward, but a local clock never shows one, so none is queued.
293
+ */
294
+ function currentRequest(request) {
295
+ if (request.protocol !== 1) return request;
296
+ const local = LOCAL_MINUTE.exec(request.now);
297
+ const now = local ? requestTime(new Date(Number(local[1]), Number(local[2]) - 1, Number(local[3]), Number(local[4]), Number(local[5]))) : request.now;
298
+ return {
299
+ ...request,
300
+ protocol: 2,
301
+ now
302
+ };
303
+ }
304
+ function readJson(file, fallback) {
305
+ if (!existsSync(file)) return fallback;
306
+ try {
307
+ return JSON.parse(readFileSync(file, "utf8"));
308
+ } catch {
309
+ return fallback;
310
+ }
311
+ }
312
+ function writeJson(file, value) {
313
+ mkdirSync(dirname(file), { recursive: true });
314
+ const temp = `${file}.${process.pid}.tmp`;
315
+ writeFileSync(temp, JSON.stringify(value, null, 1));
316
+ renameSync(temp, file);
317
+ }
318
+ const readKnown = (root) => readJson(syncPaths(root).known, {}).files ?? {};
319
+ const writeKnown = (root, files) => writeJson(syncPaths(root).known, { files });
320
+ const writeQueue = (root, entries) => writeJson(syncPaths(root).queue, { entries });
321
+ /** The queue as queue.json has it; an entry from before entries had ids gets one, written back. Call under the write lock. */
322
+ function readQueue(root) {
323
+ const entries = readJson(syncPaths(root).queue, {}).entries ?? [];
324
+ if (entries.every((entry) => typeof entry.id === "string")) return entries;
325
+ const named = entries.map((entry) => ({
326
+ ...entry,
327
+ id: entry.id ?? entryId()
328
+ }));
329
+ writeQueue(root, named);
330
+ return named;
331
+ }
332
+ /** Run `fn` under the hub's write lock, the one every mainflow writer of this machine takes. */
333
+ const underLock = (root, fn) => nodeStorage(root).lock(fn);
334
+ //#endregion
335
+ //#region ../sync/dist/swap-helper.js
336
+ const HELPER_SOURCE = "mainflow-swap.c";
337
+ /** What `mainflow-swap --version` prints; another answer is a helper this client does not speak to. */
338
+ const HELPER_PROTOCOL = "mainflow-swap 1";
339
+ /** The file name of a prebuilt helper; macOS builds one universal binary. */
340
+ const prebuiltName = (platform, arch) => `mainflow-swap-${platform}-${arch}`;
341
+ /** Where the native/ directory is: beside this module in the built package, or the source tree's dist/native. */
342
+ function nativeDirs() {
343
+ const here = dirname(fileURLToPath(import.meta.url));
344
+ return [
345
+ join(here, "native"),
346
+ join(here, "../dist/native"),
347
+ join(here, "../native")
348
+ ];
349
+ }
350
+ const works = (file) => {
351
+ try {
352
+ return execFileSync(file, ["--version"], {
353
+ encoding: "utf8",
354
+ stdio: [
355
+ "ignore",
356
+ "pipe",
357
+ "ignore"
358
+ ],
359
+ timeout: 5e3
360
+ }).trim() === HELPER_PROTOCOL;
361
+ } catch {
362
+ return false;
363
+ }
364
+ };
365
+ const cacheDir = () => join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "mainflow");
366
+ /** Compile the shipped source into the cache directory; null when there is no source or no working compiler. */
367
+ function compile(source) {
368
+ const text = readFileSync(source);
369
+ const target = join(cacheDir(), `${prebuiltName(process.platform, process.arch)}-${createHash("sha1").update(text).digest("hex").slice(0, 12)}`);
370
+ if (existsSync(target) && works(target)) return target;
371
+ mkdirSync(dirname(target), { recursive: true });
372
+ const temp = `${target}.${process.pid}.tmp`;
373
+ try {
374
+ execFileSync(process.env.CC ?? "cc", [
375
+ "-O2",
376
+ "-o",
377
+ temp,
378
+ source
379
+ ], {
380
+ stdio: "ignore",
381
+ timeout: 6e4
382
+ });
383
+ renameSync(temp, target);
384
+ return works(target) ? target : null;
385
+ } catch {
386
+ rmSync(temp, { force: true });
387
+ return null;
388
+ }
389
+ }
390
+ /** Find a working helper for this machine; see the top of this file for the order. */
391
+ function findHelper(env = process.env) {
392
+ if (process.platform !== "darwin" && process.platform !== "linux") return {
393
+ path: null,
394
+ source: "none"
395
+ };
396
+ const fromEnv = env.MAINFLOW_SWAP_HELPER;
397
+ if (fromEnv) return works(fromEnv) ? {
398
+ path: fromEnv,
399
+ source: "env"
400
+ } : {
401
+ path: null,
402
+ source: "none"
403
+ };
404
+ const names = [prebuiltName(process.platform, process.arch), prebuiltName(process.platform, "universal")];
405
+ for (const dir of nativeDirs()) for (const name of names) {
406
+ const file = join(dir, name);
407
+ if (existsSync(file) && works(file)) return {
408
+ path: file,
409
+ source: "prebuilt"
410
+ };
411
+ }
412
+ for (const dir of nativeDirs()) {
413
+ const source = join(dir, HELPER_SOURCE);
414
+ if (!existsSync(source)) continue;
415
+ const compiled = compile(source);
416
+ if (compiled) return {
417
+ path: compiled,
418
+ source: "compiled"
419
+ };
420
+ }
421
+ return {
422
+ path: null,
423
+ source: "none"
424
+ };
425
+ }
426
+ //#endregion
427
+ //#region ../sync/dist/swap.js
428
+ /** The helper, run once per exchange. */
429
+ var SwapHelper = class {
430
+ path;
431
+ constructor(path) {
432
+ this.path = path;
433
+ }
434
+ /** Exchange two existing paths; throws on an answer other than ok, missing or unsupported. */
435
+ swap(a, b) {
436
+ if (/[\t\n]/.test(a + b)) throw new Error("a path with a tab or a line break cannot be sent to the swap helper");
437
+ const run = spawnSync(this.path, [], {
438
+ input: `${a}\t${b}\n`,
439
+ encoding: "utf8",
440
+ stdio: [
441
+ "pipe",
442
+ "pipe",
443
+ "ignore"
444
+ ],
445
+ timeout: 5e3
446
+ });
447
+ if (run.error) throw run.error;
448
+ const answer = run.stdout.split("\n")[0] ?? "";
449
+ if (answer === "ok" || answer === "missing" || answer === "unsupported") return answer;
450
+ throw new Error(answer || `the swap helper exited with ${run.status}`);
451
+ }
452
+ };
453
+ /**
454
+ * Find the helper and check that it really exchanges in `dir`, the directory the client prepares files in, on
455
+ * the hub's own file system: some file systems refuse the exchange, and a FUSE file system may ignore the flag and
456
+ * overwrite instead, which would look like success.
457
+ */
458
+ function startSwapper(dir, env = process.env) {
459
+ const lookup = findHelper(env);
460
+ if (!lookup.path) return {
461
+ helper: null,
462
+ lookup,
463
+ reason: `no atomic exchange helper for ${process.platform}-${process.arch}: none is shipped and none could be compiled`
464
+ };
465
+ const helper = new SwapHelper(lookup.path);
466
+ const a = join(dir, `swap-test-${process.pid}-a`);
467
+ const b = join(dir, `swap-test-${process.pid}-b`);
468
+ try {
469
+ writeFileSync(a, "a");
470
+ writeFileSync(b, "b");
471
+ const answer = helper.swap(a, b);
472
+ if (answer === "ok" && readFileSync(a, "utf8") === "b" && readFileSync(b, "utf8") === "a") return {
473
+ helper,
474
+ lookup
475
+ };
476
+ return {
477
+ helper: null,
478
+ lookup,
479
+ reason: `the file system of ${dir} does not exchange files atomically (the helper answered ${answer})`
480
+ };
481
+ } catch (error) {
482
+ return {
483
+ helper: null,
484
+ lookup,
485
+ reason: `the atomic exchange helper failed its check: ${error.message}`
486
+ };
487
+ } finally {
488
+ rmSync(a, { force: true });
489
+ rmSync(b, { force: true });
490
+ }
491
+ }
492
+ //#endregion
493
+ //#region ../sync/dist/client.js
494
+ const HISTORY = 20;
495
+ /** A text's short content hash, for traces. */
496
+ const short = (text) => text == null ? String(text) : contentHash(text).slice(0, 7);
497
+ const SETTLE_MS = 100;
498
+ /**
499
+ * Snapshots in a row that bring neither a missing change nor anything newer before the change counts as lost; the
500
+ * ones before the last are each followed by a pause before the next
501
+ */
502
+ const STALLED_SNAPSHOTS = 4;
503
+ /** How long after a read that is not UTF-8 the file is read again before it counts as binary */
504
+ const BINARY_RECHECK_MS = 150;
505
+ /**
506
+ * How long after a file that held text reads empty it is read again before the empty file counts as written: a
507
+ * program that rewrites a file as `cat >` does empties it first and fills it a moment later
508
+ */
509
+ const EMPTY_RECHECK_MS = 300;
510
+ /** How long after the exchange the displaced file is read again, for a program that still held it open */
511
+ const LATE_WRITE_MS = 150;
512
+ /** queue.json, relative to .mainflow/: other writers append to it, and the client sends what they appended */
513
+ const OUTBOX = `${SYNC_DIR}/queue.json`;
514
+ const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
515
+ const decoder = new TextDecoder("utf-8", { fatal: true });
516
+ const sameLocal = (a, b) => a.kind === b.kind && (a.kind !== "text" || a.text === b.text) && (a.kind !== "binary" || a.hash === b.hash);
517
+ /**
518
+ * The queued edit of `path` to the writer's `text` that started from `start` (src/state.ts): on the server's version
519
+ * that holds a confirmed start, waiting for an unconfirmed one, or, without a start, on `base`, the hash of the version
520
+ * last on disk, null for a new file. `replaced`, what the file held before, is kept when the writer wrote its start
521
+ * back unchanged, for layer 2.
522
+ */
523
+ function editOn(path, text, start, base, replaced) {
524
+ if (!start) return {
525
+ kind: "patch",
526
+ path,
527
+ text,
528
+ base
529
+ };
530
+ const undone = start.text === text && replaced !== void 0 && replaced !== text ? { replaced } : {};
531
+ if (start.server === void 0) return {
532
+ kind: "patch",
533
+ path,
534
+ text,
535
+ base: null,
536
+ baseText: start.text,
537
+ ...undone
538
+ };
539
+ return {
540
+ kind: "patch",
541
+ path,
542
+ text,
543
+ base: contentHash(start.server),
544
+ baseText: start.text,
545
+ ...start.server === start.text ? {} : { serverBase: start.server },
546
+ ...undone
547
+ };
548
+ }
549
+ /** The confirmed version a queued edit's base is, when its base is known: its baseText and the server's text of `base`. */
550
+ function heldBase(entry) {
551
+ if (entry.base === null || entry.baseText === void 0) return void 0;
552
+ const server = entry.serverBase ?? entry.baseText;
553
+ return contentHash(server) === entry.base ? {
554
+ text: entry.baseText,
555
+ server
556
+ } : void 0;
557
+ }
558
+ /**
559
+ * The request an edit sends, sent at once or from the outbox: its text on its base, moved first onto the server's
560
+ * merge of its base when the server merged it (`serverBase`), with the base as the common ancestor. `unresolved` while
561
+ * its base is a local text the server has not answered for; `overlap` when the move overlaps.
562
+ */
563
+ function patchRequest(entry) {
564
+ if (entry.base === null && entry.baseText !== void 0) return "unresolved";
565
+ let text = entry.text;
566
+ if (entry.serverBase !== void 0 && entry.baseText !== void 0) {
567
+ const merged = mergeThreeWay(entry.serverBase, entry.baseText, entry.text);
568
+ if (!merged.ok) return "overlap";
569
+ text = merged.text;
570
+ }
571
+ return {
572
+ protocol: 2,
573
+ op: "file.patch",
574
+ path: entry.path,
575
+ base: entry.base,
576
+ text
577
+ };
578
+ }
579
+ var SyncClient = class extends EventEmitter {
580
+ opts;
581
+ root;
582
+ paths;
583
+ api;
584
+ problems = [];
585
+ reported = 0;
586
+ expected = /* @__PURE__ */ new Map();
587
+ /** The confirmed versions of each path, oldest first, at most HISTORY; versions() adds what the outbox holds */
588
+ history = /* @__PURE__ */ new Map();
589
+ /** The writer's texts of the direct edits and Edits sent outside the outbox that wait for an answer, by path */
590
+ unanswered = /* @__PURE__ */ new Map();
591
+ cloud = /* @__PURE__ */ new Map();
592
+ /** The hub's seq of each path's version in `cloud`: a version from the server with a seq no higher is not newer */
593
+ fileSeq = /* @__PURE__ */ new Map();
594
+ /** The hub's seq up to which the client has taken in every change: the last snapshot and the broadcasts after it */
595
+ hubSeq = -1;
596
+ /** The seqs of the broadcasts on the current connection above hubSeq with a change between that never arrived */
597
+ ahead = /* @__PURE__ */ new Set();
598
+ waiters = /* @__PURE__ */ new Set();
599
+ inflight = /* @__PURE__ */ new Map();
600
+ dirty = /* @__PURE__ */ new Set();
601
+ chains = /* @__PURE__ */ new Map();
602
+ work = /* @__PURE__ */ new Set();
603
+ binaries = /* @__PURE__ */ new Set();
604
+ signatures = /* @__PURE__ */ new Map();
605
+ /** The outbox as last read from queue.json, which every writer of this machine appends to */
606
+ queue = [];
607
+ /** What the server answered for the operations this run sent, by outbox id, for writers that wait */
608
+ outcomes = /* @__PURE__ */ new Map();
609
+ /** Outbox entries the server answered that are not out of the outbox yet; they are never sent again */
610
+ answered = /* @__PURE__ */ new Set();
611
+ /** The ids the server renumbered for an answered operation, given to the files when it leaves the outbox */
612
+ renumbering = /* @__PURE__ */ new Map();
613
+ /** The outbox entry on its way to the server */
614
+ sending = null;
615
+ ws = null;
616
+ connected = false;
617
+ stopped = false;
618
+ flushing = false;
619
+ resyncing = null;
620
+ resyncAgain = false;
621
+ /** Whether a snapshot has been taken, after which a path the server never mentioned does not exist there */
622
+ synced = false;
623
+ reconnectTimer;
624
+ pingTimer;
625
+ rescanTimer;
626
+ queueTimer;
627
+ watcher;
628
+ swapper = null;
629
+ /** Settles once the first connection attempt synced or failed */
630
+ firstContact;
631
+ backoff = 500;
632
+ lastPong = 0;
633
+ knownTimer;
634
+ constructor(opts) {
635
+ super();
636
+ this.opts = opts;
637
+ this.root = opts.root;
638
+ this.paths = syncPaths(opts.root);
639
+ this.api = new ServerApi(opts.settings);
640
+ this.firstContact = new Promise((resolve) => {
641
+ this.once("synced", resolve);
642
+ this.once("offline", resolve);
643
+ });
644
+ }
645
+ trace(line) {
646
+ this.opts.trace?.(line);
647
+ }
648
+ log(line) {
649
+ this.opts.log?.(line);
650
+ this.emit("log", line);
651
+ }
652
+ get online() {
653
+ return this.connected && !this.stopped;
654
+ }
655
+ get queued() {
656
+ return this.queue.length;
657
+ }
658
+ /** Whether the local files have every change the server has: online, with the snapshot taken and not being retaken. */
659
+ get caughtUp() {
660
+ return this.online && this.synced && this.resyncing === null;
661
+ }
662
+ /** Whether files are written with the atomic exchange. */
663
+ get swapping() {
664
+ return this.swapper !== null;
665
+ }
666
+ /**
667
+ * The file at `path` as the server last had it to this client's knowledge: its text, null when the server has no such
668
+ * file, or undefined when the client has not heard of it, such as before the first snapshot.
669
+ */
670
+ serverText(path) {
671
+ return this.cloud.has(path) ? this.cloud.get(path) : this.synced ? null : void 0;
672
+ }
673
+ /** Start: restore what the last run left, watch the files, connect, and resolve once synced or found offline. */
674
+ async start() {
675
+ await mkdir(this.paths.tmp, { recursive: true });
676
+ await mkdir(this.paths.conflicts, { recursive: true });
677
+ if (this.opts.swap !== false) {
678
+ const swapper = startSwapper(this.paths.tmp);
679
+ this.swapper = swapper.helper;
680
+ if (swapper.helper) this.log(`atomic exchange: ${swapper.lookup.source} helper ${swapper.lookup.path}`);
681
+ else {
682
+ this.log(`atomic exchange unavailable, writing with a rename: ${swapper.reason}`);
683
+ this.info("", `atomic exchange unavailable, writing with a rename: ${swapper.reason}`);
684
+ }
685
+ }
686
+ await this.restoreKnown();
687
+ await this.whenFree(() => this.syncQueue());
688
+ if (this.opts.watch !== false) this.startWatcher();
689
+ await this.scan(true, true);
690
+ const every = this.opts.rescanMs ?? 2e3;
691
+ if (every > 0) this.rescanTimer = setInterval(() => void this.scan(false).then(() => this.outboxChanged()).catch((error) => this.log(`rescan failed: ${String(error)}`)), every);
692
+ this.connect();
693
+ await this.firstContact;
694
+ }
695
+ /**
696
+ * What the last run left on disk, from known.json. A file that still holds that version is read, so it is a version
697
+ * a local edit can be based on (layer 1) as it was before the restart; without it, a program that read the file
698
+ * before a change from the server arrived would have its write based on that change and undo it.
699
+ */
700
+ async restoreKnown() {
701
+ for (const [path, hash] of Object.entries(readKnown(this.root))) {
702
+ const local = await this.readLocal(path);
703
+ if (local.kind === "text" && contentHash(local.text) === hash) {
704
+ this.expected.set(path, {
705
+ hash,
706
+ text: local.text
707
+ });
708
+ this.remember(path, {
709
+ text: local.text,
710
+ server: local.text
711
+ });
712
+ } else this.expected.set(path, { hash });
713
+ }
714
+ }
715
+ async stop() {
716
+ this.stopped = true;
717
+ clearTimeout(this.reconnectTimer);
718
+ clearInterval(this.pingTimer);
719
+ clearInterval(this.rescanTimer);
720
+ clearTimeout(this.queueTimer);
721
+ this.watcher?.close();
722
+ this.ws?.close();
723
+ await Promise.allSettled(this.chains.values());
724
+ this.saveKnownNow();
725
+ }
726
+ /** Test hook: drop the connection as a lost network would, and stay offline until reconnect(). */
727
+ disconnect() {
728
+ clearTimeout(this.reconnectTimer);
729
+ this.stopped = true;
730
+ this.ws?.close();
731
+ this.markOffline();
732
+ }
733
+ /** Test hook: the network is back. */
734
+ async reconnect() {
735
+ this.stopped = false;
736
+ await new Promise((resolve) => {
737
+ this.once("synced", resolve);
738
+ this.once("offline", resolve);
739
+ this.connect();
740
+ });
741
+ }
742
+ /** Resolve once nothing is being sent, applied or waited for, and the queue is empty or cannot be sent. */
743
+ async idle(timeoutMs = 3e4) {
744
+ const deadline = Date.now() + timeoutMs;
745
+ this.outboxChanged();
746
+ for (;;) {
747
+ if (!(this.work.size > 0 || [...this.inflight.values()].some((n) => n > 0) || this.flushing || this.resyncing !== null || this.online && this.queue.length > 0)) return true;
748
+ if (Date.now() > deadline) return false;
749
+ await (this.work.size > 0 ? Promise.race([Promise.allSettled(this.work), sleep$1(50)]) : sleep$1(50));
750
+ }
751
+ }
752
+ connect() {
753
+ if (this.stopped) return;
754
+ const ws = this.api.socket();
755
+ this.ws = ws;
756
+ this.ahead.clear();
757
+ let opened = false;
758
+ ws.addEventListener("open", () => {
759
+ opened = true;
760
+ this.diagnosed = false;
761
+ this.connected = true;
762
+ this.backoff = 500;
763
+ this.lastPong = Date.now();
764
+ clearInterval(this.pingTimer);
765
+ this.pingTimer = setInterval(() => this.ping(ws), 15e3);
766
+ this.log("connected");
767
+ this.track(this.resync().then(() => this.flushQueue()).then(() => this.emit("synced")).catch((error) => {
768
+ this.log(`could not sync: ${error.message}`);
769
+ ws.close();
770
+ }));
771
+ });
772
+ ws.addEventListener("message", (event) => {
773
+ if (this.ws === ws) this.onMessage(String(event.data));
774
+ });
775
+ const lost = () => {
776
+ if (this.ws !== ws) return;
777
+ this.ws = null;
778
+ if (opened) this.log("connection lost");
779
+ else if (!this.diagnosed) this.diagnose();
780
+ this.markOffline();
781
+ if (this.stopped) return;
782
+ this.reconnectTimer = setTimeout(() => this.connect(), this.backoff * (.5 + Math.random()));
783
+ this.backoff = Math.min(this.backoff * 2, 1e4);
784
+ };
785
+ ws.addEventListener("close", lost);
786
+ ws.addEventListener("error", lost);
787
+ }
788
+ diagnosed = false;
789
+ /** A connection refused before it opened says nothing about why; one plain request tells a wrong token apart. */
790
+ diagnose() {
791
+ this.diagnosed = true;
792
+ this.api.snapshot().then(() => this.log("the WebSocket was refused although the server answers; retrying"), (error) => this.log(`cannot connect: ${error.message}; retrying`));
793
+ }
794
+ markOffline() {
795
+ const was = this.connected;
796
+ this.connected = false;
797
+ clearInterval(this.pingTimer);
798
+ for (const waiter of this.waiters) waiter.done();
799
+ this.waiters.clear();
800
+ if (was) this.log("offline");
801
+ this.emit("offline");
802
+ }
803
+ /** A connection that stops answering pings is dead even when no close arrives, as after a server restart. */
804
+ ping(ws) {
805
+ if (Date.now() - this.lastPong > 4e4) {
806
+ this.log("no answer to pings; reconnecting");
807
+ ws.close();
808
+ if (this.ws === ws) {
809
+ this.ws = null;
810
+ this.markOffline();
811
+ this.reconnectTimer = setTimeout(() => this.connect(), 500);
812
+ }
813
+ return;
814
+ }
815
+ try {
816
+ ws.send("ping");
817
+ } catch {}
818
+ }
819
+ onMessage(data) {
820
+ if (data === "pong") {
821
+ this.lastPong = Date.now();
822
+ return;
823
+ }
824
+ this.lastPong = Date.now();
825
+ let message;
826
+ try {
827
+ message = JSON.parse(data);
828
+ } catch {
829
+ return;
830
+ }
831
+ if (!Number.isInteger(message.seq)) return;
832
+ const seq = message.seq;
833
+ if (seq === this.hubSeq + 1) this.takenUpTo(seq);
834
+ else if (seq > this.hubSeq + 1) this.ahead.add(seq);
835
+ if ("reload" in message && message.reload) {
836
+ this.track(this.resync());
837
+ return;
838
+ }
839
+ if (this.ahead.size > 0 && this.synced && this.resyncing === null) {
840
+ this.log(`missed the hub's changes ${this.hubSeq + 1} to ${Math.min(...this.ahead) - 1}; reloading the snapshot`);
841
+ this.track(this.resync());
842
+ }
843
+ for (const event of message.files) this.receive(event, seq);
844
+ this.wake();
845
+ }
846
+ /** The client has every change up to `seq`: hubSeq moves there, and on over the broadcasts that came after it in a row. */
847
+ takenUpTo(seq) {
848
+ this.hubSeq = Math.max(this.hubSeq, seq);
849
+ for (const held of this.ahead) if (held <= this.hubSeq) this.ahead.delete(held);
850
+ while (this.ahead.delete(this.hubSeq + 1)) this.hubSeq++;
851
+ }
852
+ /**
853
+ * One file as the server has it as of the hub's `seq`, from a broadcast or a snapshot. A version no newer than the
854
+ * one the client holds, such as a broadcast that arrives after a later snapshot, is left out.
855
+ */
856
+ receive(event, seq) {
857
+ if (event.kind === "dir") {
858
+ if (event.path.split("/")[0] !== "local" || event.path === "local/sessions") this.track(mkdir(join(this.paths.data, event.path), { recursive: true }));
859
+ return;
860
+ }
861
+ if (!isSynced(event.path)) return;
862
+ const text = event.kind === "file" ? event.text : null;
863
+ const held = this.fileSeq.get(event.path) ?? -1;
864
+ if (seq <= held) {
865
+ this.trace(`older ${event.path} ${short(text)} seq ${seq}, holding seq ${held}`);
866
+ return;
867
+ }
868
+ this.trace(`receive ${event.path} ${short(text)} seq ${seq}`);
869
+ this.fileSeq.set(event.path, seq);
870
+ this.cloud.set(event.path, text);
871
+ this.schedule(event.path);
872
+ }
873
+ /** Whether the client holds `path` as of the hub's `seq` or later. */
874
+ reached(path, seq) {
875
+ return this.hubSeq >= seq || (this.fileSeq.get(path) ?? -1) >= seq;
876
+ }
877
+ /** Release the waiters whose seq the client has reached. */
878
+ wake() {
879
+ for (const waiter of this.waiters) {
880
+ if (!this.reached(waiter.path, waiter.seq)) continue;
881
+ this.waiters.delete(waiter);
882
+ waiter.done();
883
+ }
884
+ }
885
+ /**
886
+ * Wait until the client holds `path` as of the hub's `seq`, the seq of a response about it: the server broadcasts
887
+ * before it responds, but on the other connection, so the broadcast may still be on its way. Without a connection,
888
+ * or after 10 s, the snapshot settles it instead.
889
+ */
890
+ async delivery(path, seq) {
891
+ if (this.reached(path, seq)) return;
892
+ if (!this.online) return;
893
+ let done;
894
+ const arrived = new Promise((resolve) => done = resolve);
895
+ const waiter = {
896
+ path,
897
+ seq,
898
+ done
899
+ };
900
+ this.waiters.add(waiter);
901
+ const timer = setTimeout(() => {
902
+ this.waiters.delete(waiter);
903
+ this.log(`no broadcast of ${path} after its response; reloading the snapshot`);
904
+ this.resync().catch(() => {}).finally(done);
905
+ }, 1e4);
906
+ await arrived;
907
+ clearTimeout(timer);
908
+ }
909
+ /**
910
+ * Take the server's whole hub and reconcile every path; a request made while one runs runs once more after it. So
911
+ * does a change still missing among the broadcasts once the snapshot is in, including ones that came while it was
912
+ * reconciled: the server broadcasts a change after storing it, so a snapshot asked for later holds it. One that
913
+ * still does not, and brought nothing newer, is asked for again after a pause; after STALLED_SNAPSHOTS of them the
914
+ * server is taken to have lost those changes, as when its storage was replaced: the versions the client holds from
915
+ * after the gap are forgotten and the snapshot is taken once more, as it is.
916
+ */
917
+ resync() {
918
+ if (this.resyncing) {
919
+ this.resyncAgain = true;
920
+ return this.resyncing;
921
+ }
922
+ this.resyncing = (async () => {
923
+ try {
924
+ let stalled = 0;
925
+ do {
926
+ this.resyncAgain = false;
927
+ const held = this.hubSeq;
928
+ await this.takeSnapshot();
929
+ if (this.ahead.size === 0) continue;
930
+ stalled = this.hubSeq > held ? 0 : stalled + 1;
931
+ const missing = `${this.hubSeq + 1} to ${Math.min(...this.ahead) - 1}`;
932
+ if (stalled >= STALLED_SNAPSHOTS) {
933
+ this.log(`the sync server's snapshot stays without the hub's changes ${missing}; taking it as complete`);
934
+ this.ahead.clear();
935
+ for (const [path, seq] of this.fileSeq) if (seq > this.hubSeq) this.fileSeq.delete(path);
936
+ stalled = 0;
937
+ this.resyncAgain = true;
938
+ continue;
939
+ }
940
+ this.log(`missed the hub's changes ${missing} while taking the snapshot; reloading it`);
941
+ if (stalled > 0) await sleep$1(250 * 2 ** (stalled - 1));
942
+ this.resyncAgain = true;
943
+ } while (this.resyncAgain);
944
+ } finally {
945
+ this.resyncing = null;
946
+ if (this.connected && this.queue.length > 0) this.track(this.flushQueue());
947
+ }
948
+ })();
949
+ return this.resyncing;
950
+ }
951
+ /**
952
+ * Take the hub as of the snapshot's seq. A broadcast arriving meanwhile may be older or newer than the snapshot; each
953
+ * file keeps whichever version has the higher seq. A server without the hub first gets the local one.
954
+ */
955
+ async takeSnapshot() {
956
+ let snapshot;
957
+ let before;
958
+ for (let upload = 0;; upload++) {
959
+ before = this.hubSeq;
960
+ snapshot = await this.api.snapshot();
961
+ if (upload > 0 || snapshot.files.some((event) => event.kind === "file") || !await this.uploadIfEmpty()) break;
962
+ }
963
+ const { seq, files } = snapshot;
964
+ if (seq < before) {
965
+ this.log(`the sync server's seq went back from ${before} to ${seq}; taking its snapshot as it is`);
966
+ this.fileSeq.clear();
967
+ this.hubSeq = -1;
968
+ this.ahead.clear();
969
+ }
970
+ const present = /* @__PURE__ */ new Set();
971
+ for (const event of files) {
972
+ if (event.kind === "file") present.add(event.path);
973
+ this.receive(event, seq);
974
+ }
975
+ for (const path of /* @__PURE__ */ new Set([...this.cloud.keys(), ...this.expected.keys()])) if (!present.has(path) && isSynced(path)) this.receive({
976
+ kind: "removed",
977
+ path
978
+ }, seq);
979
+ this.takenUpTo(seq);
980
+ this.synced = true;
981
+ await this.settleUnsynced();
982
+ this.wake();
983
+ for (const path of await this.walk()) this.schedule(path);
984
+ }
985
+ /**
986
+ * A server without the hub gets the local one; true when it was uploaded and a reload follows. The local files
987
+ * already hold what the outbox's operations did, which the server does when they are sent, so a file an operation
988
+ * changed is uploaded as it was before the first one that did (d017).
989
+ */
990
+ async uploadIfEmpty() {
991
+ const files = {};
992
+ const outbox = /* @__PURE__ */ new Map();
993
+ for (const entry of await this.whenFree(() => this.syncQueue())) {
994
+ if (entry.kind !== "op") continue;
995
+ for (const [path, before] of Object.entries(entry.before ?? {})) if (!outbox.has(path)) outbox.set(path, before);
996
+ }
997
+ for (const path of /* @__PURE__ */ new Set([...await this.walk(), ...outbox.keys()])) {
998
+ if (outbox.has(path)) {
999
+ const before = outbox.get(path);
1000
+ if (typeof before === "string") files[path] = before;
1001
+ continue;
1002
+ }
1003
+ const local = await this.readLocal(path);
1004
+ if (local.kind === "text") files[path] = local.text;
1005
+ }
1006
+ if (Object.keys(files).length === 0) return false;
1007
+ const dirs = await this.walkDirs();
1008
+ const result = await this.api.upload(files, dirs);
1009
+ if (!result.ok) {
1010
+ this.log(`upload refused: ${result.message}`);
1011
+ return false;
1012
+ }
1013
+ this.log(`uploaded the hub: ${result.files} files`);
1014
+ return true;
1015
+ }
1016
+ /** Reconcile `path` after whatever earlier work on it has finished. */
1017
+ schedule(path) {
1018
+ const next = (this.chains.get(path) ?? Promise.resolve()).then(() => this.reconcile(path)).catch((error) => this.log(`${path}: ${error.stack ?? String(error)}`));
1019
+ this.chains.set(path, next);
1020
+ this.track(next).then(() => {
1021
+ if (this.chains.get(path) === next) this.chains.delete(path);
1022
+ });
1023
+ }
1024
+ track(promise) {
1025
+ this.work.add(promise);
1026
+ promise.finally(() => this.work.delete(promise)).catch(() => {});
1027
+ return promise;
1028
+ }
1029
+ isBusy(path) {
1030
+ return (this.inflight.get(path) ?? 0) > 0 || this.online && this.queue.some((entry) => entryPaths(entry).includes(path));
1031
+ }
1032
+ async readLocal(path) {
1033
+ let bytes;
1034
+ try {
1035
+ bytes = await readFile(join(this.paths.data, path));
1036
+ } catch (error) {
1037
+ const code = error.code;
1038
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR") return { kind: "absent" };
1039
+ throw error;
1040
+ }
1041
+ try {
1042
+ return {
1043
+ kind: "text",
1044
+ text: decoder.decode(bytes)
1045
+ };
1046
+ } catch {
1047
+ return {
1048
+ kind: "binary",
1049
+ hash: contentHash(bytes.toString("latin1"))
1050
+ };
1051
+ }
1052
+ }
1053
+ /**
1054
+ * Compare the local file with what the client last left there and with the server's version, and act: send a
1055
+ * local change, write the server's version, or nothing. A path waiting for a response is marked and done after.
1056
+ */
1057
+ async reconcile(path) {
1058
+ if (this.isBusy(path)) {
1059
+ this.dirty.add(path);
1060
+ return;
1061
+ }
1062
+ const local = await this.readLocal(path);
1063
+ if (local.kind === "binary") {
1064
+ await sleep$1(BINARY_RECHECK_MS);
1065
+ const again = await this.readLocal(path);
1066
+ if (again.kind !== "binary" || again.hash !== local.hash) return this.schedule(path);
1067
+ return this.skipBinary(path, local.hash);
1068
+ }
1069
+ const seen = this.expected.get(path);
1070
+ if (seen === void 0 ? local.kind !== "absent" : !this.matches(seen, local)) {
1071
+ await this.whenFree(() => this.syncQueue());
1072
+ const now = await this.readLocal(path);
1073
+ if (!sameLocal(now, local)) return this.schedule(path);
1074
+ const after = this.expected.get(path);
1075
+ if (after !== seen && after !== void 0 && this.matches(after, local)) return this.schedule(path);
1076
+ }
1077
+ const target = this.cloud.has(path) ? this.cloud.get(path) : this.synced ? null : void 0;
1078
+ const disk = this.expected.get(path);
1079
+ if (disk === void 0) {
1080
+ if (local.kind === "absent") {
1081
+ if (typeof target === "string") await this.replace(path, target);
1082
+ return;
1083
+ }
1084
+ if (target === local.text) return this.adopt(path, local.text);
1085
+ if (target === void 0) return;
1086
+ return this.sendLocal(path, local.text, { start: target === null ? null : {
1087
+ text: local.text,
1088
+ server: local.text
1089
+ } });
1090
+ }
1091
+ if (!this.matches(disk, local)) {
1092
+ if (local.kind === "text") {
1093
+ if (local.text === "" && disk?.text) {
1094
+ await sleep$1(EMPTY_RECHECK_MS);
1095
+ const again = await this.readLocal(path);
1096
+ if (again.kind !== "text" || again.text !== "") return this.schedule(path);
1097
+ }
1098
+ return this.sendLocal(path, local.text);
1099
+ }
1100
+ if (target === null || disk === null) return this.forget(path);
1101
+ return this.sendRemove(path, disk.hash);
1102
+ }
1103
+ if (target === void 0 || (disk === null ? target === null : target !== null && disk.hash === contentHash(target))) {
1104
+ if (disk?.text !== void 0) this.remember(path, {
1105
+ text: disk.text,
1106
+ server: disk.text
1107
+ });
1108
+ return;
1109
+ }
1110
+ await this.replace(path, target);
1111
+ }
1112
+ matches(disk, local) {
1113
+ if (disk === null) return local.kind === "absent";
1114
+ return local.kind === "text" && disk.hash === contentHash(local.text);
1115
+ }
1116
+ adopt(path, text) {
1117
+ this.expected.set(path, {
1118
+ hash: contentHash(text),
1119
+ text
1120
+ });
1121
+ this.remember(path, {
1122
+ text,
1123
+ server: text
1124
+ });
1125
+ this.saveKnown();
1126
+ }
1127
+ /** A removed file starts over: its earlier versions are gone from the server and are no base for a new one. */
1128
+ forget(path) {
1129
+ this.expected.set(path, null);
1130
+ this.history.delete(path);
1131
+ this.saveKnown();
1132
+ }
1133
+ /** A confirmed version of `path`, the newest; the oldest goes beyond HISTORY. */
1134
+ remember(path, version) {
1135
+ const list = (this.history.get(path) ?? []).filter((v) => v.text !== version.text);
1136
+ list.push(version);
1137
+ if (list.length > HISTORY) list.shift();
1138
+ this.history.set(path, list);
1139
+ }
1140
+ skipBinary(path, hash) {
1141
+ const key = `${path}:${hash}`;
1142
+ if (this.binaries.has(key)) return;
1143
+ this.binaries.add(key);
1144
+ this.problem({
1145
+ kind: "skipped-binary",
1146
+ path,
1147
+ message: `${path} is not UTF-8 text, so it is not synced and not overwritten; other environments do not see it`
1148
+ });
1149
+ }
1150
+ tempName() {
1151
+ return join(this.paths.tmp, `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`);
1152
+ }
1153
+ /**
1154
+ * Put the server's version of `path` on disk. An existing file is exchanged with the prepared one in one step,
1155
+ * so whatever a local program wrote just before ends up in the prepared path and is sent, not lost; it is read
1156
+ * again shortly after, for a program that still held the old file open. A new file is linked into place, which
1157
+ * fails rather than overwrite a file created meanwhile; a removed file is first moved aside and checked.
1158
+ */
1159
+ async replace(path, target) {
1160
+ const file = join(this.paths.data, path);
1161
+ const temp = this.tempName();
1162
+ this.trace(`replacing ${path} with ${short(target)}`);
1163
+ if (target !== null) {
1164
+ await mkdir(dirname(file), { recursive: true });
1165
+ await writeFile(temp, target);
1166
+ }
1167
+ let step;
1168
+ try {
1169
+ step = await this.whenFree(() => underLock(this.root, () => this.replaceHeld(path, file, temp, target)));
1170
+ } catch (error) {
1171
+ await rm(temp, { force: true });
1172
+ throw error;
1173
+ }
1174
+ if (step.kind === "exists" || step.kind === "missing") return this.schedule(path);
1175
+ if (step.kind === "linked") return;
1176
+ if (step.kind === "removed") {
1177
+ if (step.moved !== null && step.disk && contentHash(step.moved) !== step.disk.hash) {
1178
+ const saved = await this.saveConflict(path, step.moved);
1179
+ this.problem({
1180
+ kind: "conflict",
1181
+ path,
1182
+ saved,
1183
+ message: `${path} was removed by another environment while it was being written here; the text written here was saved to ${saved}`
1184
+ });
1185
+ }
1186
+ return;
1187
+ }
1188
+ const { displaced, swapped, from } = step;
1189
+ this.trace(`wrote ${path} ${short(target)}${swapped ? " (exchanged)" : ""}; displaced ${short(displaced)}`);
1190
+ const changed = displaced !== null && contentHash(displaced) !== from.over.hash;
1191
+ const later = swapped && changed && displaced === "";
1192
+ if (changed && !later) {
1193
+ this.log(`${path}: a local write landed while the server's version was written; sending it`);
1194
+ await this.sendLocal(path, displaced, { displaced: from });
1195
+ }
1196
+ if (swapped) this.track(this.recheck(path, temp, later ? null : displaced, from));
1197
+ else await rm(temp, { force: true });
1198
+ }
1199
+ /**
1200
+ * The part of replace done under the hub's write lock. The outbox is taken in first: every operation in it was
1201
+ * written on the file as the client expects it before this replacement, and none can be appended until the lock
1202
+ * is released, so an operation is never compared with a version it was not written on (the invariants at the top).
1203
+ */
1204
+ replaceHeld(path, file, temp, target) {
1205
+ this.editHeld();
1206
+ const disk = this.expected.get(path);
1207
+ const read = (at) => {
1208
+ try {
1209
+ return readFileSync(at, "utf8");
1210
+ } catch {
1211
+ return null;
1212
+ }
1213
+ };
1214
+ if (target === null) {
1215
+ try {
1216
+ renameSync(file, temp);
1217
+ } catch (error) {
1218
+ if (error.code !== "ENOENT") throw error;
1219
+ }
1220
+ this.forget(path);
1221
+ const moved = read(temp);
1222
+ rmSync(temp, { force: true });
1223
+ return {
1224
+ kind: "removed",
1225
+ moved,
1226
+ disk
1227
+ };
1228
+ }
1229
+ if (!disk) {
1230
+ try {
1231
+ linkSync(temp, file);
1232
+ } catch (error) {
1233
+ rmSync(temp, { force: true });
1234
+ if (error.code !== "EEXIST") throw error;
1235
+ return { kind: "exists" };
1236
+ }
1237
+ rmSync(temp, { force: true });
1238
+ this.wrote(path, target);
1239
+ return { kind: "linked" };
1240
+ }
1241
+ let displaced = null;
1242
+ let swapped = false;
1243
+ let hooked = false;
1244
+ if (this.swapper) {
1245
+ this.opts.beforeReplace?.(file);
1246
+ hooked = true;
1247
+ let answer;
1248
+ try {
1249
+ answer = this.swapper.swap(temp, file);
1250
+ } catch (error) {
1251
+ this.log(`atomic exchange failed (${error.message}); writing with a rename from now on`);
1252
+ this.swapper = null;
1253
+ answer = "unsupported";
1254
+ }
1255
+ if (answer === "missing") {
1256
+ rmSync(temp, { force: true });
1257
+ return { kind: "missing" };
1258
+ }
1259
+ if (answer === "ok") {
1260
+ swapped = true;
1261
+ displaced = read(temp);
1262
+ } else if (this.swapper) {
1263
+ this.log("the file system refused the atomic exchange; writing with a rename from now on");
1264
+ this.swapper = null;
1265
+ }
1266
+ }
1267
+ if (!swapped) {
1268
+ displaced = read(file);
1269
+ if (!hooked) this.opts.beforeReplace?.(file);
1270
+ renameSync(temp, file);
1271
+ }
1272
+ const among = new Set(this.versions(path).map((v) => v.text));
1273
+ this.wrote(path, target);
1274
+ return {
1275
+ kind: "written",
1276
+ displaced,
1277
+ swapped,
1278
+ from: {
1279
+ over: disk,
1280
+ among
1281
+ }
1282
+ };
1283
+ }
1284
+ /**
1285
+ * A program that held the old file open may write into it after the exchange; what it wrote is sent. `taken` is the
1286
+ * displaced text replace sent or found unchanged; null when it sent nothing, as for an empty file, which is read
1287
+ * again only after EMPTY_RECHECK_MS and sent as it is then, empty too. The old file held `taken`, so what was written
1288
+ * into it later may be based on it; the server's versions written since the exchange stay out.
1289
+ */
1290
+ async recheck(path, temp, taken, from) {
1291
+ await sleep$1(taken === null ? EMPTY_RECHECK_MS : LATE_WRITE_MS);
1292
+ const later = await readFile(temp, "utf8").catch(() => null);
1293
+ await rm(temp, { force: true });
1294
+ if (later !== null && later !== taken && contentHash(later) !== from.over.hash) {
1295
+ this.log(`${path}: a late write into the replaced file; sending it`);
1296
+ const held = taken === null ? from : {
1297
+ ...from,
1298
+ among: /* @__PURE__ */ new Set([...from.among, taken])
1299
+ };
1300
+ await this.withChain(path, () => this.sendLocal(path, later, { displaced: held }));
1301
+ }
1302
+ }
1303
+ withChain(path, fn) {
1304
+ const next = (this.chains.get(path) ?? Promise.resolve()).then(fn).catch((error) => this.log(`${path}: ${String(error)}`));
1305
+ this.chains.set(path, next);
1306
+ return this.track(next);
1307
+ }
1308
+ wrote(path, text) {
1309
+ this.expected.set(path, {
1310
+ hash: contentHash(text),
1311
+ text
1312
+ });
1313
+ this.remember(path, {
1314
+ text,
1315
+ server: text
1316
+ });
1317
+ this.saveKnown();
1318
+ }
1319
+ /**
1320
+ * Every version a local write of `path` may have started from, oldest first (docs/sync.md "直接改檔的基準"): the
1321
+ * confirmed ones kept in memory; from the outbox, the base of each queued edit whose base is known, and, unconfirmed,
1322
+ * what each queued write left on disk; and, unconfirmed, each direct edit waiting for its answer. A write answered
1323
+ * but still queued counts as unconfirmed until it leaves the outbox, in the same hold that fills in the bases on it.
1324
+ * Only the entries before `upto` count, for an entry whose base can come only from the writes ahead of it. A text
1325
+ * both confirmed and unconfirmed is unconfirmed, since only the answer to the write that put it on disk confirms
1326
+ * it; the outbox and the requests in flight hold every unconfirmed text, so a restart reads them back.
1327
+ */
1328
+ versions(path, entries = this.queue, upto = entries.length) {
1329
+ const out = /* @__PURE__ */ new Map();
1330
+ for (const version of this.history.get(path) ?? []) out.set(version.text, version);
1331
+ const ahead = entries.slice(0, upto);
1332
+ for (const entry of ahead) {
1333
+ const held = entry.kind === "patch" && entry.path === path ? heldBase(entry) : void 0;
1334
+ if (held && !out.has(held.text)) out.set(held.text, held);
1335
+ }
1336
+ for (const entry of ahead) {
1337
+ const text = entry.kind === "op" ? entry.written[path] : entry.kind === "patch" && entry.path === path ? entry.text : void 0;
1338
+ if (typeof text === "string") out.set(text, { text });
1339
+ }
1340
+ for (const text of this.unanswered.get(path) ?? []) out.set(text, { text });
1341
+ return [...out.values()];
1342
+ }
1343
+ /**
1344
+ * Layer 1: the version `text` most likely started from, among versions() up to `upto` and, when given, in `among`:
1345
+ * the one with the fewest differing lines, the newest on a tie; undefined when there is none.
1346
+ */
1347
+ startOf(path, text, before, scope = {}) {
1348
+ const all = this.versions(path, scope.entries, scope.upto).filter((v) => scope.among?.has(v.text) ?? true);
1349
+ const candidates = this.opts.nearestBase === false ? all.filter((v) => v.text === before?.text || v.server === before?.text).slice(-1) : all;
1350
+ let best;
1351
+ let bestDistance = Infinity;
1352
+ for (const version of candidates) {
1353
+ const distance = lineDistance(version.text, text);
1354
+ if (distance <= bestDistance) {
1355
+ best = version;
1356
+ bestDistance = distance;
1357
+ }
1358
+ }
1359
+ return best;
1360
+ }
1361
+ /**
1362
+ * Send a local text of `path`, at once when the client has caught up and its base is confirmed, through the outbox
1363
+ * otherwise. A text on disk is what the client now expects there. A `displaced` text, one the server's version took
1364
+ * off disk, is not: it was written over the version the client had left there, and on a version the old file held
1365
+ * (layer 3). `start` overrides layer 1: the version the writer started from, or null for a new file.
1366
+ */
1367
+ async sendLocal(path, text, options = {}) {
1368
+ const { displaced } = options;
1369
+ const before = displaced ? displaced.over : this.expected.get(path);
1370
+ const last = before?.text;
1371
+ if (!displaced) {
1372
+ this.expected.set(path, {
1373
+ hash: contentHash(text),
1374
+ text
1375
+ });
1376
+ this.saveKnown();
1377
+ }
1378
+ const write = editOn(path, text, options.start !== void 0 ? options.start ?? void 0 : before === null ? void 0 : this.startOf(path, text, before, { among: displaced?.among }), options.start === null ? null : before?.hash ?? null, last);
1379
+ this.trace(`local ${path} ${short(text)} on disk ${short(before?.text ?? null)} versions [${this.versions(path).map((v) => `${short(v.text)}→${v.server === void 0 ? "?" : short(v.server)}`).join(" ")}] chose base ${write.base?.slice(0, 7) ?? "null"} baseText ${short(write.baseText ?? null)}${write.serverBase === void 0 ? "" : ` serverBase ${short(write.serverBase)}`}`);
1380
+ if (!this.caughtUp) return this.enqueue(write);
1381
+ const request = patchRequest(write);
1382
+ if (request === "unresolved") return this.enqueue(write);
1383
+ if (request === "overlap") {
1384
+ const saved = await this.saveConflict(path, text);
1385
+ return this.problem({
1386
+ kind: "conflict",
1387
+ path,
1388
+ saved,
1389
+ message: `${path} was edited on top of an earlier edit that the server merged with other changes, and the two edits overlap; the text written here was saved to ${saved}`
1390
+ });
1391
+ }
1392
+ await this.sendEdit(write, request, {
1393
+ writer: text,
1394
+ base: write.baseText,
1395
+ last
1396
+ });
1397
+ }
1398
+ /**
1399
+ * Send a direct edit at once, outside the outbox. While it waits for its answer, the writer's text is a version the
1400
+ * server has not confirmed, so layer 1 can tell a later write based on it from one based on an older version. When
1401
+ * the connection breaks after sending, the edit goes into the outbox with the very request, under its requestId, so
1402
+ * the server answers it again if it took it the first time; one never sent goes in to be sent later.
1403
+ */
1404
+ async sendEdit(write, request, context) {
1405
+ const { path } = write;
1406
+ this.inflight.set(path, (this.inflight.get(path) ?? 0) + 1);
1407
+ this.awaitAnswer(path, write.text);
1408
+ const id = entryId();
1409
+ const firstSentSeq = this.hubSeq;
1410
+ const queued = this.queuedIds();
1411
+ this.trace(`patch ${path} ${id} base ${request.base?.slice(0, 7) ?? "null"} text ${short(request.text)} writer ${short(context.writer)} baseText ${short(context.base ?? null)}`);
1412
+ try {
1413
+ let response;
1414
+ try {
1415
+ response = await this.api.send({
1416
+ ...request,
1417
+ requestId: id
1418
+ });
1419
+ } catch (error) {
1420
+ this.trace(`patch ${path} failed: ${error.message}`);
1421
+ if (!(error instanceof Unreachable)) throw error;
1422
+ return await this.enqueue(write, {
1423
+ queued,
1424
+ ...error.sent ? { sent: {
1425
+ id,
1426
+ firstSentSeq,
1427
+ sent: request
1428
+ } } : {}
1429
+ });
1430
+ }
1431
+ await this.settleEdit(path, response, context, request.text);
1432
+ } finally {
1433
+ this.answeredDirect(path, write.text);
1434
+ }
1435
+ }
1436
+ /** The outbox entries as last read, for a write sent outside it: what was queued when it was sent. */
1437
+ queuedIds() {
1438
+ return new Set(this.queue.map((entry) => entry.id));
1439
+ }
1440
+ /** A direct edit or Edit sent outside the outbox now waits for its answer. */
1441
+ awaitAnswer(path, text) {
1442
+ this.unanswered.set(path, [...this.unanswered.get(path) ?? [], text]);
1443
+ }
1444
+ /**
1445
+ * A direct edit or Edit sent outside the outbox was answered, or queued: its text is confirmed, or held by the
1446
+ * outbox. The outbox is read again, which fills in the base of the edits queued on it, and sent.
1447
+ */
1448
+ answeredDirect(path, text) {
1449
+ const texts = this.unanswered.get(path) ?? [];
1450
+ texts.splice(texts.indexOf(text), 1);
1451
+ if (texts.length === 0) this.unanswered.delete(path);
1452
+ this.release(path);
1453
+ if (this.queue.length > 0) this.outboxChanged();
1454
+ }
1455
+ release(path) {
1456
+ const count = (this.inflight.get(path) ?? 1) - 1;
1457
+ if (count > 0) this.inflight.set(path, count);
1458
+ else this.inflight.delete(path);
1459
+ this.dirty.delete(path);
1460
+ this.schedule(path);
1461
+ }
1462
+ /** What a response to a direct edit means for the writer (layer 2), then wait for the broadcast it caused. */
1463
+ async settleEdit(path, response, context, sent) {
1464
+ this.trace(`response ${path} ${response.ok ? `${response.files.map((f) => `${f.path} ${f.kind === "file" ? short(f.text) : f.kind}`).join(", ")}` : `${response.reason}: ${response.message}`}`);
1465
+ if (response.ok) {
1466
+ const file = response.files.find((f) => f.path === path);
1467
+ const result = file?.kind === "file" ? file.text : file?.kind === "removed" ? null : this.cloud.get(path) ?? sent ?? context.writer;
1468
+ if (typeof result === "string") this.remember(path, {
1469
+ text: context.writer,
1470
+ server: result
1471
+ });
1472
+ if (typeof result === "string") this.classify(path, context, result);
1473
+ if (file) await this.delivery(path, response.seq);
1474
+ return;
1475
+ }
1476
+ const saved = await this.saveConflict(path, context.writer);
1477
+ if (response.reason === "conflict") this.problem({
1478
+ kind: "conflict",
1479
+ path,
1480
+ saved,
1481
+ message: `${path}: the server refused the change (${response.message}); the text written here was saved to ${saved} and the file now has the server's version`
1482
+ });
1483
+ else this.problem({
1484
+ kind: "refused",
1485
+ path,
1486
+ saved,
1487
+ message: `${path}: the server refused the change (${response.message}); the text written here was saved to ${saved}`
1488
+ });
1489
+ if ("files" in response && response.files.some((f) => f.path === path)) await this.delivery(path, response.seq);
1490
+ }
1491
+ /**
1492
+ * Layer 2. Report only when the writer's own content may be lost: lines it added that the result lacks, or lines
1493
+ * it removed that the result still has. A writer that rewrote an older version unchanged, while the disk had
1494
+ * other environments' additions, is taken as deleting those additions, which were kept. A result that only adds
1495
+ * other environments' changes to the writer's text is information.
1496
+ */
1497
+ classify(path, context, result) {
1498
+ const { writer, base, last } = context;
1499
+ if (result === writer) return;
1500
+ const w = writer.split("\n");
1501
+ const r = result.split("\n");
1502
+ const inResult = alignLines(w, r).keptA;
1503
+ if (base === void 0) {
1504
+ const lost = w.filter((line, i) => !inResult[i] && line.trim() !== "");
1505
+ if (lost.length) return this.problem({
1506
+ kind: "merge-lost",
1507
+ path,
1508
+ message: `${path}: the merged file lacks ${lost.length} line(s) of the text written here, such as "${lost[0]}"; read it and redo what is missing`
1509
+ });
1510
+ return this.info(path, "merged with changes from other environments");
1511
+ }
1512
+ const b = base.split("\n");
1513
+ const own = alignLines(b, w);
1514
+ const added = w.filter((line, i) => !own.keptB[i] && !inResult[i] && line.trim() !== "");
1515
+ if (added.length) return this.problem({
1516
+ kind: "merge-lost",
1517
+ path,
1518
+ message: `${path}: the merged file lacks ${added.length} line(s) of the text written here, such as "${added[0]}"; read it and redo what is missing`
1519
+ });
1520
+ const baseInResult = alignLines(b, r).keptA;
1521
+ const undone = b.filter((line, i) => !own.keptA[i] && baseInResult[i] && line.trim() !== "");
1522
+ if (undone.length) return this.problem({
1523
+ kind: "deletion-undone",
1524
+ path,
1525
+ message: `${path}: ${undone.length} line(s) removed here are still in the merged file, such as "${undone[0]}"; delete through a mainflow command or the Edit tool`
1526
+ });
1527
+ if (writer === base && last !== void 0 && last !== writer) {
1528
+ const onDisk = last.split("\n");
1529
+ const dropped = alignLines(onDisk, w).keptA;
1530
+ const kept = alignLines(onDisk, r).keptA;
1531
+ const reverted = onDisk.filter((line, i) => !dropped[i] && kept[i] && line.trim() !== "");
1532
+ if (reverted.length) return this.problem({
1533
+ kind: "deletion-undone",
1534
+ path,
1535
+ message: `${path}: the write removed ${reverted.length} line(s) another environment added, such as "${reverted[0]}"; they were kept. Delete through a mainflow command or the Edit tool`
1536
+ });
1537
+ }
1538
+ this.info(path, "merged with changes from other environments");
1539
+ }
1540
+ /** Send a deletion at once, outside the outbox; after a broken connection the same request goes into the outbox, as in sendEdit. */
1541
+ async sendRemove(path, base) {
1542
+ this.forget(path);
1543
+ if (!this.caughtUp) return this.enqueue({
1544
+ kind: "remove",
1545
+ path,
1546
+ base
1547
+ });
1548
+ const id = entryId();
1549
+ const firstSentSeq = this.hubSeq;
1550
+ const request = {
1551
+ protocol: 2,
1552
+ op: "file.remove",
1553
+ input: {
1554
+ path,
1555
+ base
1556
+ },
1557
+ now: requestTime()
1558
+ };
1559
+ const queued = this.queuedIds();
1560
+ this.inflight.set(path, (this.inflight.get(path) ?? 0) + 1);
1561
+ try {
1562
+ let unsent = false;
1563
+ const response = await this.api.send({
1564
+ ...request,
1565
+ requestId: id
1566
+ }).catch((error) => {
1567
+ if (!(error instanceof Unreachable)) throw error;
1568
+ unsent = !error.sent;
1569
+ return null;
1570
+ });
1571
+ if (response === null) return this.enqueue({
1572
+ kind: "remove",
1573
+ path,
1574
+ base
1575
+ }, {
1576
+ queued,
1577
+ ...unsent ? {} : { sent: {
1578
+ id,
1579
+ firstSentSeq,
1580
+ sent: request
1581
+ } }
1582
+ });
1583
+ await this.settleRemove(path, response);
1584
+ } finally {
1585
+ this.release(path);
1586
+ }
1587
+ }
1588
+ async settleRemove(path, response) {
1589
+ if (!response.ok) return this.problem({
1590
+ kind: "refused",
1591
+ path,
1592
+ message: `${path}: the server refused the deletion (${response.message})`
1593
+ });
1594
+ for (const notice of response.notices) this.problem({
1595
+ kind: "removal-kept",
1596
+ path: notice.path,
1597
+ message: `${notice.message} (deleted here; the file is back)`
1598
+ });
1599
+ if (response.files.some((f) => f.path === path)) await this.delivery(path, response.seq);
1600
+ }
1601
+ /**
1602
+ * What an outbox entry left on disk is what the client expects there. The versions it holds need nothing more: the
1603
+ * outbox is where versions() reads them.
1604
+ */
1605
+ restoreEntry(entry) {
1606
+ if (entry.kind === "op") for (const [path, text] of Object.entries(entry.written)) this.expected.set(path, text === null ? null : {
1607
+ hash: contentHash(text),
1608
+ text
1609
+ });
1610
+ else if (entry.kind === "patch") this.expected.set(entry.path, {
1611
+ hash: contentHash(entry.text),
1612
+ text: entry.text
1613
+ });
1614
+ else this.expected.set(entry.path, null);
1615
+ }
1616
+ /**
1617
+ * Change the outbox: read queue.json, take in the entries other writers appended since it was last read, let `edit`
1618
+ * change the list, and write it back, all under the hub's write lock; `edit` returns false when it changed nothing.
1619
+ * Without `edit` it only writes back what it changed itself. The files an appended operation wrote become what the
1620
+ * client expects on disk, so they are not sent again as edits; a direct edit they held before it, not sent yet, goes
1621
+ * ahead of it. Every hold fills in the base of the queued edits whose base the server has confirmed since
1622
+ * (resolveBases), before `edit` and after it, so the entry that confirmed it leaves the outbox in the same hold.
1623
+ */
1624
+ editQueue(edit) {
1625
+ return underLock(this.root, () => this.editHeld(edit));
1626
+ }
1627
+ /** editQueue for a caller that holds the write lock already. */
1628
+ editHeld(edit) {
1629
+ const entries = readQueue(this.root);
1630
+ const known = new Set(this.queue.map((entry) => entry.id));
1631
+ let changed = false;
1632
+ for (let i = 0; i < entries.length; i++) {
1633
+ const entry = entries[i];
1634
+ if (known.has(entry.id)) continue;
1635
+ if (entry.kind === "op" && !entry.checked) {
1636
+ const ahead = this.editsBefore(entries, i);
1637
+ entries.splice(i, 0, ...ahead);
1638
+ i += ahead.length;
1639
+ entry.checked = true;
1640
+ changed = true;
1641
+ }
1642
+ this.restoreEntry(entry);
1643
+ }
1644
+ if (this.resolveBases(entries)) changed = true;
1645
+ if (edit && edit(entries) !== false) {
1646
+ changed = true;
1647
+ this.resolveBases(entries);
1648
+ }
1649
+ if (changed) writeQueue(this.root, entries);
1650
+ this.queue = entries;
1651
+ return entries;
1652
+ }
1653
+ /**
1654
+ * A file never synced here, as it was before an operation, against the server's version: nothing when they are the
1655
+ * same; otherwise sent as reconcile sends such a file, with its own text as the base, so the server keeps its version
1656
+ * when it holds this text among its recent ones and refuses it otherwise, which saves it and reports.
1657
+ */
1658
+ firstEdit(path, before) {
1659
+ const target = this.cloud.get(path) ?? null;
1660
+ if (target === before) return null;
1661
+ return target === null ? {
1662
+ kind: "patch",
1663
+ path,
1664
+ text: before,
1665
+ base: null
1666
+ } : {
1667
+ kind: "patch",
1668
+ path,
1669
+ text: before,
1670
+ base: contentHash(before),
1671
+ baseText: before
1672
+ };
1673
+ }
1674
+ /**
1675
+ * After the snapshot, before anything is sent: the comparisons editsBefore left in the outbox for files never synced
1676
+ * here (`unsettled`), made now that the server's versions are known. A hub the server did not have was uploaded as
1677
+ * it was before the outbox (uploadIfEmpty), so they are the same and nothing is added.
1678
+ */
1679
+ async settleUnsynced() {
1680
+ if (!this.queue.some((entry) => entry.kind === "op" && entry.unsettled?.length)) return;
1681
+ await this.whenFree(() => this.editQueue((entries) => {
1682
+ for (let i = 0; i < entries.length; i++) {
1683
+ const entry = entries[i];
1684
+ if (entry.kind !== "op" || !entry.unsettled?.length) continue;
1685
+ const ahead = [];
1686
+ for (const path of entry.unsettled) {
1687
+ const before = entry.before?.[path];
1688
+ const write = typeof before === "string" ? this.firstEdit(path, before) : null;
1689
+ if (!write) continue;
1690
+ this.trace(`direct edit ${path} ${short(before)} ahead of ${entry.request.op} ${entry.id}: ${write.kind} (never synced here)`);
1691
+ this.log(`${path}: it held a direct edit when ${entry.request.op} was written; the edit is sent first`);
1692
+ ahead.push({
1693
+ ...write,
1694
+ id: entryId()
1695
+ });
1696
+ }
1697
+ delete entry.unsettled;
1698
+ entries.splice(i, 0, ...ahead);
1699
+ i += ahead.length;
1700
+ }
1701
+ }));
1702
+ }
1703
+ /**
1704
+ * The direct edits an operation's files held when it was applied and the client had not sent: each file whose
1705
+ * content before the operation is not what the client expected there. They go ahead of the operation, so the server
1706
+ * has them when it applies it and the version it broadcasts back includes them (docs/sync.md "寫入的遠端模式").
1707
+ */
1708
+ editsBefore(entries, at) {
1709
+ const entry = entries[at];
1710
+ const out = [];
1711
+ for (const [path, before] of Object.entries(entry.before ?? {})) {
1712
+ const seen = this.expected.get(path);
1713
+ let write = null;
1714
+ if (seen === void 0) {
1715
+ if (before === null) continue;
1716
+ if (!this.synced) {
1717
+ entry.unsettled = [...entry.unsettled ?? [], path];
1718
+ continue;
1719
+ }
1720
+ write = this.firstEdit(path, before);
1721
+ } else if (before === null) {
1722
+ if (seen !== null) write = {
1723
+ kind: "remove",
1724
+ path,
1725
+ base: seen.hash
1726
+ };
1727
+ } else if (seen === null) write = {
1728
+ kind: "patch",
1729
+ path,
1730
+ text: before,
1731
+ base: null
1732
+ };
1733
+ else if (seen.hash !== contentHash(before)) write = editOn(path, before, this.startOf(path, before, seen, {
1734
+ entries,
1735
+ upto: at
1736
+ }), seen.hash, seen.text);
1737
+ if (!write) continue;
1738
+ this.trace(`direct edit ${path} ${short(before)} ahead of ${entry.request.op} ${entry.id}: ${write.kind}`);
1739
+ this.log(`${path}: it held a direct edit when ${entry.request.op} was written; the edit is sent first`);
1740
+ out.push({
1741
+ ...write,
1742
+ id: entryId()
1743
+ });
1744
+ }
1745
+ return out;
1746
+ }
1747
+ /**
1748
+ * Fill in the base of each queued edit based on a local text the server has confirmed since (src/state.ts): the hash
1749
+ * of the server's version that holds it, and that version's text when the server merged it with other changes. Only
1750
+ * the writes ahead of an edit and the confirmed versions count, since its base can come from nothing later. An edit
1751
+ * whose base the server refused keeps a null base, and is refused when it is first sent. True when it changed one.
1752
+ */
1753
+ resolveBases(entries) {
1754
+ let changed = false;
1755
+ entries.forEach((entry, at) => {
1756
+ if (entry.kind !== "patch" || entry.base !== null || entry.baseText === void 0 || entry.sent !== void 0) return;
1757
+ const start = this.versions(entry.path, entries, at).find((v) => v.text === entry.baseText);
1758
+ if (start?.server === void 0) return;
1759
+ Object.assign(entry, editOn(entry.path, entry.text, start, null));
1760
+ this.trace(`based ${entry.id} on ${short(start.server)}`);
1761
+ changed = true;
1762
+ });
1763
+ return changed;
1764
+ }
1765
+ syncQueue() {
1766
+ return this.editQueue();
1767
+ }
1768
+ /**
1769
+ * Run `fn`, which takes the write lock, again after a moment while another writer holds the lock longer than a
1770
+ * writer waits for it. A lock left behind by a writer that died goes after LOCK_STALE_MS (core), so this ends.
1771
+ */
1772
+ async whenFree(fn) {
1773
+ for (;;) try {
1774
+ return fn();
1775
+ } catch (error) {
1776
+ if (!(error instanceof WriteError) || this.stopped) throw error;
1777
+ this.log(`${error.message}; trying again`);
1778
+ await sleep$1(500);
1779
+ }
1780
+ }
1781
+ /** queue.json changed, or may have: take in what other writers appended and send it. */
1782
+ outboxChanged() {
1783
+ const before = this.queue.length;
1784
+ this.track(this.whenFree(() => this.syncQueue()).then(() => {
1785
+ if (this.queue.length !== before) this.log(`outbox: ${this.queue.length} waiting`);
1786
+ if (this.online && this.queue.length > 0) this.track(this.flushQueue());
1787
+ }).catch((error) => this.log(`outbox: ${error.message}`)));
1788
+ }
1789
+ /**
1790
+ * Queue a write, as an entry of its own: an edit keeps the base layer 1 chose for it (docs/sync.md "直接改檔的基準").
1791
+ * A write sent outside the outbox (`from`) that could not be answered goes ahead of the entries queued after it was
1792
+ * sent, the order in which it was written, since they may be based on it; `sent` is its request when the connection
1793
+ * broke after sending, queued as it was, under its requestId, so the server answers it again if it took it. Any
1794
+ * other write goes after every write queued already.
1795
+ */
1796
+ async enqueue(write, from) {
1797
+ await this.whenFree(() => this.editQueue((entries) => {
1798
+ const entry = {
1799
+ ...write,
1800
+ id: entryId(),
1801
+ ...from?.sent
1802
+ };
1803
+ const at = from ? entries.findIndex((queued) => !from.queued.has(queued.id)) : -1;
1804
+ if (at < 0) entries.push(entry);
1805
+ else entries.splice(at, 0, entry);
1806
+ }));
1807
+ this.log(`queued ${write.kind} ${entryPaths(write).join(", ")} (${this.queue.length} waiting)`);
1808
+ if (this.online) this.track(this.flushQueue());
1809
+ }
1810
+ /**
1811
+ * Send the outbox in order, one at a time, each after the previous one's response. An answered entry leaves the
1812
+ * outbox under the write lock; while another writer holds the lock longer than a writer waits for it, the entry
1813
+ * stays and is taken out on a later try, without being sent again. Before an entry is first sent, the request it
1814
+ * sends and its firstSentSeq are written to the outbox (`sent`), so it is sent again as it was even after a restart,
1815
+ * and nothing is merged into it. An edit whose base cannot be moved onto the server's version is not sent.
1816
+ */
1817
+ async flushQueue() {
1818
+ if (this.flushing) return;
1819
+ this.flushing = true;
1820
+ try {
1821
+ while (this.caughtUp) {
1822
+ let entry;
1823
+ let again = false;
1824
+ let unmergeable = false;
1825
+ let waiting = false;
1826
+ this.editQueue((entries) => {
1827
+ entry = entries[0];
1828
+ if (!entry || this.answered.has(entry.id)) return false;
1829
+ again = entry.firstSentSeq !== void 0;
1830
+ if (entry.sent) return false;
1831
+ const request = this.prepare(entry);
1832
+ if (request === "wait") {
1833
+ waiting = true;
1834
+ return false;
1835
+ }
1836
+ if (!request) {
1837
+ unmergeable = true;
1838
+ return false;
1839
+ }
1840
+ entry.sent = request;
1841
+ entry.firstSentSeq ??= this.hubSeq;
1842
+ });
1843
+ if (!entry || waiting) break;
1844
+ if (unmergeable) await this.refuseUnmergeable(entry);
1845
+ else if (!this.answered.has(entry.id)) {
1846
+ this.sending = entry.id;
1847
+ if (!await this.sendEntry(entry, again).finally(() => this.sending = null)) return this.retryQueue();
1848
+ }
1849
+ this.finish(entry);
1850
+ }
1851
+ } catch (error) {
1852
+ this.log(`outbox: ${error.message}; trying again shortly`);
1853
+ this.retryQueue();
1854
+ } finally {
1855
+ this.flushing = false;
1856
+ }
1857
+ }
1858
+ retryQueue() {
1859
+ clearTimeout(this.queueTimer);
1860
+ if (!this.stopped) this.queueTimer = setTimeout(() => void this.track(this.flushQueue()), 1e3 + Math.random() * 1e3);
1861
+ }
1862
+ /**
1863
+ * Take an answered entry out of the outbox, and give the ids the server renumbered to the files written here and
1864
+ * to the entries after it, in one hold of the write lock (docs/sync-protocol.md). Throws while the lock is taken.
1865
+ */
1866
+ finish(entry) {
1867
+ const renumbered = this.renumbering.get(entry.id);
1868
+ this.editQueue((entries) => {
1869
+ if (renumbered) this.renumber(entries, entry, renumbered);
1870
+ const at = entries.findIndex((e) => e.id === entry.id);
1871
+ if (at >= 0) entries.splice(at, 1);
1872
+ });
1873
+ this.answered.delete(entry.id);
1874
+ this.renumbering.delete(entry.id);
1875
+ if (renumbered) this.log(`renumbered ${renumbered.map((r) => `${r.from} → ${r.to}`).join(", ")}`);
1876
+ for (const path of entryPaths(entry)) this.schedule(path);
1877
+ this.emit("settled", entry.id);
1878
+ }
1879
+ /**
1880
+ * What became of outbox entry `id`, waiting up to `timeoutMs` while it is still in the outbox and the client is
1881
+ * online: the server's answer for an operation this run sent, waiting when offline or out of time, or unknown for
1882
+ * one that left the outbox otherwise.
1883
+ */
1884
+ async outcome(id, timeoutMs) {
1885
+ const deadline = Date.now() + timeoutMs;
1886
+ const answered = () => this.outcomes.get(id);
1887
+ if (answered()) return answered();
1888
+ try {
1889
+ this.syncQueue();
1890
+ } catch {}
1891
+ if (!this.queue.some((entry) => entry.id === id)) return { state: "unknown" };
1892
+ await Promise.race([this.firstContact, sleep$1(timeoutMs)]);
1893
+ if (!this.online || Date.now() >= deadline) return answered() ?? { state: "waiting" };
1894
+ this.track(this.flushQueue());
1895
+ return new Promise((resolve) => {
1896
+ const finish = (outcome) => {
1897
+ clearTimeout(timer);
1898
+ this.off("settled", done);
1899
+ this.off("offline", away);
1900
+ resolve(outcome);
1901
+ };
1902
+ const done = (settled) => settled === id && finish(answered() ?? { state: "unknown" });
1903
+ const away = () => finish({ state: "waiting" });
1904
+ const timer = setTimeout(() => finish(answered() ?? { state: "waiting" }), deadline - Date.now());
1905
+ this.on("settled", done);
1906
+ this.on("offline", away);
1907
+ });
1908
+ }
1909
+ settled(id, outcome) {
1910
+ this.outcomes.set(id, outcome);
1911
+ if (this.outcomes.size > 500) this.outcomes.delete(this.outcomes.keys().next().value);
1912
+ }
1913
+ /** The server answered an outbox entry, or it was settled without sending: it is not sent again. */
1914
+ answer(entry) {
1915
+ this.answered.add(entry.id);
1916
+ this.trace(`answered ${entry.id}`);
1917
+ }
1918
+ /**
1919
+ * Send one write of the outbox under the entry's id as its requestId; null when the server cannot be reached, so it
1920
+ * stays queued. A connection that broke after sending needs no warning: the request goes again under the same id,
1921
+ * and a server that took it answers with the response it kept. `again` says it may have been sent before, so it
1922
+ * carries firstSentSeq; when the server no longer keeps the responses that far back and cannot tell whether it
1923
+ * applied it, the writer is told and it is sent once more as a new request.
1924
+ */
1925
+ async request(entry, request, again) {
1926
+ const firstSentSeq = again ? entry.firstSentSeq : void 0;
1927
+ this.trace(`send ${request.op} ${entry.id}${firstSentSeq === void 0 ? "" : ` again, first sent at seq ${firstSentSeq}`}`);
1928
+ let response;
1929
+ try {
1930
+ response = await this.api.send(firstSentSeq === void 0 ? {
1931
+ ...request,
1932
+ requestId: entry.id
1933
+ } : {
1934
+ ...request,
1935
+ requestId: entry.id,
1936
+ firstSentSeq
1937
+ });
1938
+ } catch (error) {
1939
+ if (!(error instanceof Unreachable)) throw error;
1940
+ this.trace(`send ${entry.id} failed: ${error.message}`);
1941
+ return null;
1942
+ }
1943
+ if (!response.ok && response.reason === "uncertain") {
1944
+ const paths = entryPaths(entry);
1945
+ this.problem({
1946
+ kind: "uncertain",
1947
+ path: paths[0] ?? "",
1948
+ message: `${request.op} of ${paths.join(", ")} was sent before without an answer, and the sync server no longer keeps its answers from then (${response.message}); it is sent again as a new request and may apply twice, so check the result for a duplicate`
1949
+ });
1950
+ return this.request(entry, request, false);
1951
+ }
1952
+ return response;
1953
+ }
1954
+ /**
1955
+ * The request an outbox entry sends, made when it is first sent, from the entry alone: an operation on the current
1956
+ * protocol, a deletion with the time it is first sent, an edit as patchRequest makes it. `wait` for an edit based on
1957
+ * a direct edit still waiting for its answer. Null for an edit that cannot be sent: its base is a write the server
1958
+ * refused or did not take as it was, so its base never became known, or moving it onto the server's merge overlaps.
1959
+ */
1960
+ prepare(entry) {
1961
+ if (entry.kind === "op") return currentRequest(entry.request);
1962
+ if (entry.kind === "remove") return {
1963
+ protocol: 2,
1964
+ op: "file.remove",
1965
+ input: {
1966
+ path: entry.path,
1967
+ base: entry.base
1968
+ },
1969
+ now: requestTime()
1970
+ };
1971
+ const request = patchRequest(entry);
1972
+ if (typeof request === "object") return request;
1973
+ return request === "unresolved" && this.unanswered.get(entry.path)?.includes(entry.baseText) ? "wait" : null;
1974
+ }
1975
+ /** An edit prepare cannot send: its text is saved and the writer told; it is not sent. */
1976
+ async refuseUnmergeable(entry) {
1977
+ const saved = await this.saveConflict(entry.path, entry.text);
1978
+ this.answer(entry);
1979
+ this.problem({
1980
+ kind: "conflict",
1981
+ path: entry.path,
1982
+ saved,
1983
+ message: `${entry.path} was edited on top of a write the server did not take as it was, or the edit overlaps what the server merged into it; the text written here was saved to ${saved}`
1984
+ });
1985
+ }
1986
+ /** Send one queued entry as `sent`; false when the server cannot be reached, so it stays queued. `again`: see request. */
1987
+ async sendEntry(entry, again) {
1988
+ const request = entry.sent;
1989
+ const paths = entryPaths(entry);
1990
+ for (const path of paths) this.inflight.set(path, (this.inflight.get(path) ?? 0) + 1);
1991
+ try {
1992
+ const response = await this.request(entry, request, again);
1993
+ if (response === null) return false;
1994
+ this.answer(entry);
1995
+ if (entry.kind === "op") await this.settleQueuedOp(entry, response);
1996
+ else if (request.op === "file.remove") await this.settleRemove(entry.path, response);
1997
+ else if (entry.kind === "patch" && request.op === "text.replace") await this.settleReplace(entry.path, response, entry.text);
1998
+ else if (entry.kind === "patch" && request.op === "file.patch") await this.settleEdit(entry.path, response, {
1999
+ writer: entry.text,
2000
+ base: entry.baseText,
2001
+ last: entry.replaced
2002
+ }, request.text);
2003
+ return true;
2004
+ } finally {
2005
+ for (const path of paths) {
2006
+ const count = (this.inflight.get(path) ?? 1) - 1;
2007
+ if (count > 0) this.inflight.set(path, count);
2008
+ else this.inflight.delete(path);
2009
+ }
2010
+ }
2011
+ }
2012
+ /** A queued operation answered: tie what was written here to the server's versions, or report a refusal. */
2013
+ async settleQueuedOp(entry, response) {
2014
+ if (!response.ok) {
2015
+ const saved = [];
2016
+ for (const [path, text] of Object.entries(entry.written)) {
2017
+ if (text === null) continue;
2018
+ const at = await this.saveConflict(path, text);
2019
+ saved.push(at);
2020
+ this.problem({
2021
+ kind: "refused",
2022
+ path,
2023
+ saved: at,
2024
+ message: `the server refused ${entry.request.op} (${response.message}); ${path} as it was written here was saved to ${at} and the file has the server's version again`
2025
+ });
2026
+ }
2027
+ this.settled(entry.id, {
2028
+ state: "refused",
2029
+ reason: response.reason,
2030
+ message: response.message,
2031
+ saved
2032
+ });
2033
+ return;
2034
+ }
2035
+ this.settled(entry.id, {
2036
+ state: "applied",
2037
+ response
2038
+ });
2039
+ if (response.renumbered.length > 0) this.renumbering.set(entry.id, response.renumbered);
2040
+ for (const file of response.files) {
2041
+ if (file.kind !== "file") continue;
2042
+ const written = Object.entries(entry.written).find(([path]) => renumberPath(path, response.renumbered) === file.path)?.[1];
2043
+ const local = written == null ? void 0 : renumberText(written, response.renumbered, scopeOfPath(file.path));
2044
+ if (local !== void 0) this.remember(file.path, {
2045
+ text: local,
2046
+ server: file.text
2047
+ });
2048
+ }
2049
+ await Promise.all(response.files.filter((f) => f.kind !== "dir").map((f) => this.delivery(f.path, response.seq)));
2050
+ }
2051
+ /**
2052
+ * The server gave some of the entry's provisional ids away: rewrite the files written offline, the queued requests
2053
+ * and edits after it, and what the client expects on disk (docs/sync-protocol.md). Called under the write lock with
2054
+ * the outbox as read, so a writer appending meanwhile sees either the old ids everywhere or the new. An entry sent
2055
+ * already (`sent`), such as a direct edit whose answer was lost, stays as the server may have taken it, and so do
2056
+ * the files only it wrote; the server's version of them comes back once it is answered. An edit queued on top of it
2057
+ * keeps that text as its base, so its renumbering is what it changes.
2058
+ */
2059
+ renumber(entries, entry, renumbered) {
2060
+ const rest = entries.filter((queued) => queued.id !== entry.id && queued.sent === void 0);
2061
+ const offlinePaths = [...entryPaths(entry), ...rest.flatMap(entryPaths)];
2062
+ const storage = this.trackingStorage();
2063
+ this.commitTracked(storage, renumberFiles(storage, offlinePaths, renumbered));
2064
+ const files = (record) => Object.fromEntries(Object.entries(record).map(([path, text]) => {
2065
+ const to = renumberPath(path, renumbered);
2066
+ return [to, text === null ? null : renumberText(text, renumbered, scopeOfPath(to))];
2067
+ }));
2068
+ const sentText = new Set(entries.flatMap((queued) => queued.id !== entry.id && queued.kind === "patch" && queued.sent !== void 0 ? [`${queued.path}\0${queued.text}`] : []));
2069
+ for (const queued of entries) {
2070
+ if (queued.id === entry.id || queued.kind !== "patch" || queued.sent === void 0 || offlinePaths.includes(queued.path)) continue;
2071
+ if (renumberText(queued.text, renumbered, scopeOfPath(queued.path)) === queued.text) continue;
2072
+ this.info(queued.path, `sent before ${renumbered.map((r) => `${r.from} → ${r.to}`).join(", ")} was known, so it keeps the ids as the server took them`);
2073
+ }
2074
+ for (const queued of rest) if (queued.kind === "op") {
2075
+ queued.request = renumberRequest(queued.request, renumbered);
2076
+ queued.written = files(queued.written);
2077
+ if (queued.before) queued.before = files(queued.before);
2078
+ } else {
2079
+ const from = queued.path;
2080
+ queued.path = renumberPath(queued.path, renumbered);
2081
+ if (queued.kind === "patch") {
2082
+ queued.text = renumberText(queued.text, renumbered, scopeOfPath(queued.path));
2083
+ if (queued.replaced !== void 0) queued.replaced = renumberText(queued.replaced, renumbered, scopeOfPath(queued.path));
2084
+ if (queued.baseText !== void 0 && !sentText.has(`${from}\0${queued.baseText}`)) queued.baseText = renumberText(queued.baseText, renumbered, scopeOfPath(queued.path));
2085
+ }
2086
+ }
2087
+ }
2088
+ /**
2089
+ * The local files as a hub storage whose writes the client expects, so they are not taken for local edits; the
2090
+ * renumbered outbox holds them as versions. It does not take the write lock: its caller holds it.
2091
+ */
2092
+ trackingStorage() {
2093
+ const base = {
2094
+ ...nodeStorage(this.root),
2095
+ lock: (fn) => fn()
2096
+ };
2097
+ const track = (path) => {
2098
+ if (!isSynced(path)) return;
2099
+ const text = base.read(path);
2100
+ this.expected.set(path, text === null ? null : {
2101
+ hash: contentHash(text),
2102
+ text
2103
+ });
2104
+ };
2105
+ return {
2106
+ ...base,
2107
+ write: (path, text) => {
2108
+ base.write(path, text);
2109
+ track(path);
2110
+ },
2111
+ append: (path, text) => {
2112
+ base.append(path, text);
2113
+ track(path);
2114
+ },
2115
+ remove: (path) => {
2116
+ base.remove(path);
2117
+ track(path);
2118
+ }
2119
+ };
2120
+ }
2121
+ commitTracked(storage, changes) {
2122
+ for (const change of changes) if (change.kind === "write") storage.write(change.path, change.text);
2123
+ else if (change.kind === "append") storage.append(change.path, change.text);
2124
+ else if (change.kind === "makeDir") storage.makeDir(change.path);
2125
+ else storage.remove(change.path);
2126
+ }
2127
+ /** Problems no caller has been given yet; the watcher may have sent a change before the hook asked. */
2128
+ drain() {
2129
+ const out = this.problems.slice(this.reported);
2130
+ this.reported = this.problems.length;
2131
+ return out;
2132
+ }
2133
+ /** After a tool ran: find every change now, wait for the server, and return what went wrong. */
2134
+ async flush(timeoutMs = 2e4) {
2135
+ await this.scan(true);
2136
+ this.outboxChanged();
2137
+ await this.idle(timeoutMs);
2138
+ return {
2139
+ problems: this.drain(),
2140
+ queued: this.queue.length,
2141
+ online: this.online
2142
+ };
2143
+ }
2144
+ /**
2145
+ * After an Edit: replay the replacement on the server's latest version, so an Edit made while a broadcast was on
2146
+ * its way is not mistaken for a rewrite of the older file. Anything else is flushed as usual.
2147
+ */
2148
+ async flushEdit(edit, timeoutMs = 2e4) {
2149
+ const { path } = edit;
2150
+ if (this.caughtUp && isSynced(path) && edit.old !== "") await this.withChain(path, () => this.replayEdit(edit));
2151
+ return this.flush(timeoutMs);
2152
+ }
2153
+ async replayEdit({ path, old, new: replacement }) {
2154
+ for (let i = 0; i < 400 && this.isBusy(path); i++) await sleep$1(50);
2155
+ const disk = this.expected.get(path);
2156
+ const local = await this.readLocal(path);
2157
+ if (local.kind !== "text" || disk?.text === void 0 || contentHash(local.text) === disk.hash) return;
2158
+ if (disk.text.split(old).length !== 2 || disk.text.replace(old, () => replacement) !== local.text) return;
2159
+ this.expected.set(path, {
2160
+ hash: contentHash(local.text),
2161
+ text: local.text
2162
+ });
2163
+ const write = editOn(path, local.text, this.versions(path).find((v) => v.text === disk.text) ?? {
2164
+ text: disk.text,
2165
+ server: disk.text
2166
+ }, disk.hash);
2167
+ const request = {
2168
+ protocol: 2,
2169
+ op: "text.replace",
2170
+ path,
2171
+ old,
2172
+ new: replacement
2173
+ };
2174
+ const id = entryId();
2175
+ const firstSentSeq = this.hubSeq;
2176
+ this.trace(`replace ${path} ${id}`);
2177
+ this.inflight.set(path, (this.inflight.get(path) ?? 0) + 1);
2178
+ this.awaitAnswer(path, local.text);
2179
+ const queued = this.queuedIds();
2180
+ try {
2181
+ let response;
2182
+ try {
2183
+ response = await this.api.send({
2184
+ ...request,
2185
+ requestId: id
2186
+ });
2187
+ } catch (error) {
2188
+ if (!(error instanceof Unreachable)) throw error;
2189
+ return await this.enqueue(write, {
2190
+ queued,
2191
+ ...error.sent ? { sent: {
2192
+ id,
2193
+ firstSentSeq,
2194
+ sent: request
2195
+ } } : {}
2196
+ });
2197
+ }
2198
+ await this.settleReplace(path, response, local.text);
2199
+ } finally {
2200
+ this.answeredDirect(path, local.text);
2201
+ }
2202
+ }
2203
+ /** What the server answered to an Edit replayed on its version (`writer` is the edited text). */
2204
+ async settleReplace(path, response, writer) {
2205
+ if (response.ok) {
2206
+ const file = response.files.find((f) => f.path === path);
2207
+ const result = file?.kind === "file" ? file.text : this.cloud.get(path) ?? writer;
2208
+ if (typeof result === "string") this.remember(path, {
2209
+ text: writer,
2210
+ server: result
2211
+ });
2212
+ if (file) await this.delivery(path, response.seq);
2213
+ return;
2214
+ }
2215
+ const saved = await this.saveConflict(path, writer);
2216
+ this.problem({
2217
+ kind: "edit-rejected",
2218
+ path,
2219
+ saved,
2220
+ message: `${path}: the Edit could not be replayed on the latest version (${response.message}); the edited text was saved to ${saved} and the file now has the latest version. Read it and edit again`
2221
+ });
2222
+ if ("files" in response && response.files.some((f) => f.path === path)) await this.delivery(path, response.seq);
2223
+ }
2224
+ async walk(dir = "") {
2225
+ const out = [];
2226
+ let entries;
2227
+ try {
2228
+ entries = await readdir(join(this.paths.data, dir), { withFileTypes: true });
2229
+ } catch {
2230
+ return out;
2231
+ }
2232
+ for (const entry of entries) {
2233
+ const path = dir ? `${dir}/${entry.name}` : entry.name;
2234
+ if (entry.isDirectory()) {
2235
+ if (mayHoldSynced(path)) out.push(...await this.walk(path));
2236
+ } else if (entry.isFile() && isSynced(path)) out.push(path);
2237
+ }
2238
+ return out;
2239
+ }
2240
+ async walkDirs(dir = "") {
2241
+ const out = [];
2242
+ const entries = await readdir(join(this.paths.data, dir), { withFileTypes: true }).catch(() => []);
2243
+ for (const entry of entries) {
2244
+ const path = dir ? `${dir}/${entry.name}` : entry.name;
2245
+ if (!entry.isDirectory() || path === "local" || path.startsWith("local/")) continue;
2246
+ out.push(path, ...await this.walkDirs(path));
2247
+ }
2248
+ return out;
2249
+ }
2250
+ /**
2251
+ * Compare every synced file's size, modification time and inode with the last scan and reconcile what changed,
2252
+ * plus every file expected on disk that is gone. A file modified in the last moment may still be being written,
2253
+ * so it waits for the next scan unless `immediate`. `baseline` only records what is there.
2254
+ */
2255
+ async scan(immediate, baseline = false) {
2256
+ const seen = /* @__PURE__ */ new Set();
2257
+ for (const path of await this.walk()) {
2258
+ seen.add(path);
2259
+ const info = await stat(join(this.paths.data, path)).catch(() => null);
2260
+ if (!info) continue;
2261
+ const signature = `${info.mtimeMs}:${info.size}:${info.ino}`;
2262
+ if (this.signatures.get(path) === signature) continue;
2263
+ if (!baseline && !immediate && Date.now() - info.mtimeMs < 150) continue;
2264
+ this.signatures.set(path, signature);
2265
+ if (!baseline) this.schedule(path);
2266
+ }
2267
+ if (baseline) return;
2268
+ for (const [path, disk] of this.expected) if (disk !== null && !seen.has(path)) {
2269
+ this.signatures.delete(path);
2270
+ this.schedule(path);
2271
+ }
2272
+ if (immediate) await Promise.allSettled(this.chains.values());
2273
+ }
2274
+ startWatcher() {
2275
+ const timers = /* @__PURE__ */ new Map();
2276
+ try {
2277
+ this.watcher = watch(this.paths.data, { recursive: true }, (_event, name) => {
2278
+ if (!name) return;
2279
+ const path = String(name).split(sep).join("/");
2280
+ if (path === OUTBOX) {
2281
+ clearTimeout(timers.get(path));
2282
+ timers.set(path, setTimeout(() => {
2283
+ timers.delete(path);
2284
+ this.outboxChanged();
2285
+ }, SETTLE_MS));
2286
+ return;
2287
+ }
2288
+ if (!isSynced(path)) return;
2289
+ clearTimeout(timers.get(path));
2290
+ timers.set(path, setTimeout(() => {
2291
+ timers.delete(path);
2292
+ this.track(this.whenSettled(path).then(() => this.schedule(path)));
2293
+ }, SETTLE_MS));
2294
+ });
2295
+ this.watcher.on("error", (error) => this.log(`file watcher stopped: ${error.message}; the rescan continues`));
2296
+ } catch (error) {
2297
+ this.log(`no file watcher (${error.message}); relying on the rescan`);
2298
+ }
2299
+ }
2300
+ async whenSettled(path) {
2301
+ const file = join(this.paths.data, path);
2302
+ let previous = "";
2303
+ for (let i = 0; i < 20; i++) {
2304
+ const info = await stat(file).catch(() => null);
2305
+ const signature = info ? `${info.mtimeMs}:${info.size}` : "absent";
2306
+ if (signature === previous) return;
2307
+ previous = signature;
2308
+ await sleep$1(SETTLE_MS / 2);
2309
+ }
2310
+ }
2311
+ problem(problem) {
2312
+ this.problems.push(problem);
2313
+ this.log(`! ${problem.message}`);
2314
+ this.emit("problem", problem);
2315
+ appendFile(this.paths.notices, `${JSON.stringify({
2316
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2317
+ level: "warn",
2318
+ ...problem
2319
+ })}\n`).catch(() => {});
2320
+ }
2321
+ /** Something worth recording that does not concern the writer, such as a merge that only added others' changes. */
2322
+ info(path, message) {
2323
+ this.emit("info", {
2324
+ path,
2325
+ message
2326
+ });
2327
+ appendFile(this.paths.notices, `${JSON.stringify({
2328
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2329
+ level: "info",
2330
+ path,
2331
+ message
2332
+ })}\n`).catch(() => {});
2333
+ }
2334
+ async saveConflict(path, text) {
2335
+ const name = `${path.split("/").join("__")}.${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.${Math.random().toString(36).slice(2, 6)}`;
2336
+ await mkdir(this.paths.conflicts, { recursive: true });
2337
+ await writeFile(join(this.paths.conflicts, name), text);
2338
+ return relative(this.paths.data, join(this.paths.conflicts, name)).split(sep).join("/");
2339
+ }
2340
+ saveKnown() {
2341
+ if (this.knownTimer) return;
2342
+ this.knownTimer = setTimeout(() => this.saveKnownNow(), 200);
2343
+ }
2344
+ saveKnownNow() {
2345
+ clearTimeout(this.knownTimer);
2346
+ this.knownTimer = void 0;
2347
+ const files = {};
2348
+ for (const [path, disk] of this.expected) if (disk) files[path] = disk.hash;
2349
+ try {
2350
+ underLock(this.root, () => writeKnown(this.root, files));
2351
+ } catch (error) {
2352
+ this.log(`could not save local/sync/known.json: ${error.message}`);
2353
+ }
2354
+ }
2355
+ };
2356
+ //#endregion
2357
+ //#region ../sync/dist/ipc.js
2358
+ const SECRET_HEADER = "x-mainflow-sync";
2359
+ /** The running sync client of the hub at `root`, or null when none runs. */
2360
+ function readDaemon(root) {
2361
+ const file = syncPaths(root).daemon;
2362
+ if (!existsSync(file)) return null;
2363
+ try {
2364
+ const info = JSON.parse(readFileSync(file, "utf8"));
2365
+ process.kill(info.pid, 0);
2366
+ return info;
2367
+ } catch {
2368
+ return null;
2369
+ }
2370
+ }
2371
+ /**
2372
+ * The client could not be reached, though daemon.json names it; the caller works without it. `silent` when the port
2373
+ * took the request but gave no answer in time, as a sync client busy for a moment does; otherwise nothing that
2374
+ * answers as the hub's sync client is there, such as when its process ended and another took its pid.
2375
+ */
2376
+ var DaemonUnavailable = class extends Error {
2377
+ silent;
2378
+ constructor(message, silent = false) {
2379
+ super(message);
2380
+ this.silent = silent;
2381
+ }
2382
+ };
2383
+ /** Another sync client already runs for the hub. */
2384
+ var DaemonRunning = class extends Error {};
2385
+ async function callDaemon(daemon, route, body, timeoutMs = 6e4) {
2386
+ let response;
2387
+ try {
2388
+ response = await fetch(`http://127.0.0.1:${daemon.port}${route}`, {
2389
+ method: body === void 0 ? "GET" : "POST",
2390
+ headers: {
2391
+ [SECRET_HEADER]: daemon.secret,
2392
+ "content-type": "application/json"
2393
+ },
2394
+ body: body === void 0 ? void 0 : JSON.stringify(body),
2395
+ signal: AbortSignal.timeout(timeoutMs)
2396
+ });
2397
+ } catch (error) {
2398
+ const silent = error.name === "TimeoutError";
2399
+ throw new DaemonUnavailable(`mainflow sync (pid ${daemon.pid}) does not answer: ${error.message}`, silent);
2400
+ }
2401
+ let parsed;
2402
+ try {
2403
+ parsed = await response.json();
2404
+ } catch {
2405
+ throw new DaemonUnavailable(`what answers on port ${daemon.port} is not mainflow sync (pid ${daemon.pid})`);
2406
+ }
2407
+ if (response.status === 403) throw new DaemonUnavailable(`what answers on port ${daemon.port} is not this hub's mainflow sync (pid ${daemon.pid})`);
2408
+ if (!response.ok) throw new Error(`mainflow sync failed: ${parsed.error}`);
2409
+ return parsed;
2410
+ }
2411
+ /** How long a probe waits for the sync client's answer; a client holding the write lock answers after it. */
2412
+ const PROBE_MS = 2e3;
2413
+ /** The sync client daemon.json names, asked whether it runs; null without daemon.json or a live process at its pid. */
2414
+ async function probeDaemon(root, timeoutMs = PROBE_MS) {
2415
+ const daemon = readDaemon(root);
2416
+ if (!daemon) return null;
2417
+ try {
2418
+ return {
2419
+ state: "running",
2420
+ daemon,
2421
+ status: await callDaemon(daemon, "/status", void 0, timeoutMs)
2422
+ };
2423
+ } catch (error) {
2424
+ if (!(error instanceof DaemonUnavailable)) return {
2425
+ state: "running",
2426
+ daemon
2427
+ };
2428
+ return {
2429
+ state: error.silent ? "silent" : "gone",
2430
+ daemon
2431
+ };
2432
+ }
2433
+ }
2434
+ /** What to do about a sync client whose port takes requests and does not answer. */
2435
+ const silentHint = (daemon) => `its port ${daemon.port} takes requests and does not answer; if it stays so, stop pid ${daemon.pid} or delete .mainflow/local/sync/daemon.json`;
2436
+ const sameDaemon = (a, b) => a !== null && a.pid === b.pid && a.port === b.port && a.secret === b.secret;
2437
+ /**
2438
+ * Remove daemon.json when it still says what `gone` said, under the write lock, so a sync client that claimed the hub
2439
+ * meanwhile keeps it. Call holding the lock.
2440
+ */
2441
+ function forgetDaemon(root, gone) {
2442
+ if (!sameDaemon(readDaemon(root), gone)) return false;
2443
+ rmSync(syncPaths(root).daemon, { force: true });
2444
+ return true;
2445
+ }
2446
+ /** Serve `client` to this machine's mainflow commands until closed. */
2447
+ async function serveIpc(root, client) {
2448
+ const secret = randomBytes(16).toString("hex");
2449
+ const server = createServer(async (req, res) => {
2450
+ const reply = (status, value) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value));
2451
+ if (req.headers["x-mainflow-sync"] !== secret) return reply(403, { error: "wrong secret" });
2452
+ const chunks = [];
2453
+ for await (const chunk of req) chunks.push(chunk);
2454
+ let body = {};
2455
+ try {
2456
+ body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
2457
+ } catch {
2458
+ return reply(400, { error: "the body is not JSON" });
2459
+ }
2460
+ try {
2461
+ if (req.url === "/wait") return reply(200, await client.outcome(String(body.id), typeof body.timeoutMs === "number" ? body.timeoutMs : 0));
2462
+ if (req.url === "/flush") {
2463
+ const edit = body.edit;
2464
+ return reply(200, edit ? await client.flushEdit(edit) : await client.flush());
2465
+ }
2466
+ if (req.url === "/status") return reply(200, {
2467
+ online: client.online,
2468
+ caughtUp: client.caughtUp,
2469
+ queued: client.queued,
2470
+ swapping: client.swapping,
2471
+ problems: client.problems.length
2472
+ });
2473
+ if (req.url === "/file") return reply(200, { text: client.serverText(String(body.path)) });
2474
+ return reply(404, { error: `no route ${req.url}` });
2475
+ } catch (error) {
2476
+ return reply(500, { error: error.message });
2477
+ }
2478
+ });
2479
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
2480
+ const port = server.address().port;
2481
+ const file = syncPaths(root).daemon;
2482
+ const probe = await probeDaemon(root);
2483
+ const other = underLock(root, () => {
2484
+ const running = readDaemon(root);
2485
+ const reused = running !== null && probe?.state === "gone" && sameDaemon(running, probe.daemon);
2486
+ if (running && running.pid !== process.pid && !reused) return running;
2487
+ mkdirSync(syncPaths(root).own, { recursive: true });
2488
+ writeFileSync(file, JSON.stringify({
2489
+ pid: process.pid,
2490
+ port,
2491
+ secret
2492
+ }), { mode: 384 });
2493
+ return null;
2494
+ });
2495
+ if (other) {
2496
+ server.closeAllConnections();
2497
+ await new Promise((resolve) => server.close(() => resolve()));
2498
+ throw new DaemonRunning(`mainflow sync already runs for this hub (pid ${other.pid})${probe?.state === "silent" && sameDaemon(other, probe.daemon) ? `; ${silentHint(other)}` : ""}`);
2499
+ }
2500
+ return {
2501
+ port,
2502
+ close: async () => {
2503
+ try {
2504
+ if (JSON.parse(readFileSync(file, "utf8")).pid === process.pid) rmSync(file, { force: true });
2505
+ } catch {}
2506
+ server.closeAllConnections();
2507
+ await new Promise((resolve) => server.close(() => resolve()));
2508
+ }
2509
+ };
2510
+ }
2511
+ //#endregion
2512
+ //#region ../sync/dist/daemon.js
2513
+ async function startDaemon(options) {
2514
+ const client = new SyncClient(options);
2515
+ const ipc = await serveIpc(options.root, client);
2516
+ try {
2517
+ await client.start();
2518
+ } catch (error) {
2519
+ await ipc.close();
2520
+ throw error;
2521
+ }
2522
+ return {
2523
+ client,
2524
+ stop: async () => {
2525
+ await ipc.close();
2526
+ await client.stop();
2527
+ }
2528
+ };
2529
+ }
2530
+ //#endregion
2531
+ //#region ../sync/dist/hook.js
2532
+ /** Tools that write the one file named in file_path. */
2533
+ const FILE_TOOLS = /* @__PURE__ */ new Set([
2534
+ "Edit",
2535
+ "Write",
2536
+ "MultiEdit",
2537
+ "NotebookEdit"
2538
+ ]);
2539
+ /**
2540
+ * What to flush after the tool in `payload` ran in the hub at `root`. A file tool outside the hub's synced files
2541
+ * needs nothing; an Edit of one replacement is replayed on the latest version; anything else, such as a Bash
2542
+ * command that may have written any file, rescans every file.
2543
+ */
2544
+ function planFlush(root, payload) {
2545
+ const tool = payload.tool_name ?? "";
2546
+ if (!FILE_TOOLS.has(tool)) return { skip: false };
2547
+ const input = payload.tool_input ?? {};
2548
+ if (typeof input.file_path !== "string") return { skip: false };
2549
+ const file = isAbsolute(input.file_path) ? input.file_path : resolve(payload.cwd ?? process.cwd(), input.file_path);
2550
+ const path = relative(join(root, DATA_DIR), file).split(sep).join("/");
2551
+ if (path.startsWith("../") || path === ".." || isAbsolute(path) || !isSynced(path)) return { skip: true };
2552
+ if (tool === "Edit" && input.replace_all !== true && typeof input.old_string === "string" && typeof input.new_string === "string") return {
2553
+ skip: false,
2554
+ edit: {
2555
+ path,
2556
+ old: input.old_string,
2557
+ new: input.new_string
2558
+ }
2559
+ };
2560
+ return { skip: false };
2561
+ }
2562
+ /** The PostToolUse answer: block with the problems as the reason, or nothing when the change went through as written. */
2563
+ function hookAnswer(result) {
2564
+ if (result.problems.length === 0) return null;
2565
+ const reason = [
2566
+ "mainflow sync: a change to .mainflow/ did not reach the hub as it was written.",
2567
+ ...result.problems.map((p) => `- ${p.message}`),
2568
+ "Read the affected files again, check that they hold your change and the other environments' changes, and redo what is missing."
2569
+ ].join("\n");
2570
+ return JSON.stringify({
2571
+ decision: "block",
2572
+ reason
2573
+ });
2574
+ }
2575
+ //#endregion
2576
+ //#region ../sync/dist/settings.js
2577
+ const ENV = {
2578
+ url: "MAINFLOW_SYNC_URL",
2579
+ token: "MAINFLOW_SYNC_TOKEN"
2580
+ };
2581
+ /** The hub's name as config.yaml gives it. */
2582
+ function hubName(root) {
2583
+ const config = parse$1(readFileSync(join(root, DATA_DIR, "config.yaml"), "utf8"));
2584
+ if (typeof config?.hub !== "string") throw new Error(`${join(root, DATA_DIR, "config.yaml")} names no hub`);
2585
+ return config.hub;
2586
+ }
2587
+ /** The sync settings of the hub at `root`, or null when either the server or the token is missing. */
2588
+ function syncSettings(root, env = process.env) {
2589
+ const file = join(root, DATA_DIR, SETTINGS_FILE);
2590
+ let stored = {};
2591
+ if (existsSync(file)) try {
2592
+ stored = JSON.parse(readFileSync(file, "utf8")) ?? {};
2593
+ } catch (error) {
2594
+ throw new Error(`${file} is not JSON: ${error.message}`);
2595
+ }
2596
+ const url = env[ENV.url] || (typeof stored.url === "string" ? stored.url : "");
2597
+ const token = env[ENV.token] || (typeof stored.token === "string" ? stored.token : "");
2598
+ if (!url || !token) return null;
2599
+ return {
2600
+ url: url.replace(/\/+$/, ""),
2601
+ token,
2602
+ hub: hubName(root)
2603
+ };
2604
+ }
2605
+ //#endregion
2606
+ //#region ../sync/dist/outbox.js
2607
+ /** Whether the hub at `root` writes through the sync server. */
2608
+ function remoteMode(root, env = process.env) {
2609
+ return readDaemon(root) !== null || syncSettings(root, env) !== null;
2610
+ }
2611
+ /** Refusals of an operation by the local files, as opposed to a write lock held too long; thrown as they are. */
2612
+ const refusals = /* @__PURE__ */ new WeakSet();
2613
+ /** Whether `error`, thrown by queueOperation, is the operation's refusal by the local files. */
2614
+ const refusedHere = (error) => error instanceof Error && refusals.has(error);
2615
+ /**
2616
+ * Apply `request` to the local files and append it to the outbox, under one hold of the hub's write lock. The sync
2617
+ * client reads the outbox under the same lock, so it never sees the files this changed without the entry that
2618
+ * explains them, and never takes them for an edit to send. A refusal here is thrown, and nothing is queued.
2619
+ */
2620
+ function queueOperation(root, request) {
2621
+ const storage = nodeStorage(root);
2622
+ return storage.lock(() => {
2623
+ const before = {};
2624
+ const keep = (path) => {
2625
+ if (isSynced(path) && !(path in before)) before[path] = storage.read(path);
2626
+ };
2627
+ const held = {
2628
+ ...storage,
2629
+ lock: (fn) => fn(),
2630
+ write: (path, text) => (keep(path), storage.write(path, text)),
2631
+ append: (path, text) => (keep(path), storage.append(path, text)),
2632
+ remove: (path) => (keep(path), storage.remove(path))
2633
+ };
2634
+ let applied;
2635
+ try {
2636
+ applied = applyOffline(held, request);
2637
+ } catch (error) {
2638
+ if (error instanceof WriteError) refusals.add(error);
2639
+ throw error;
2640
+ }
2641
+ const { result, pending } = applied;
2642
+ const written = {};
2643
+ for (const change of result.changes) if (change.kind !== "makeDir" && isSynced(change.path)) written[change.path] = held.read(change.path);
2644
+ const id = entryId();
2645
+ writeQueue(root, [...readQueue(root), {
2646
+ id,
2647
+ kind: "op",
2648
+ request: pending,
2649
+ before: Object.fromEntries(Object.keys(written).map((path) => [path, before[path] ?? null])),
2650
+ written
2651
+ }]);
2652
+ return {
2653
+ id,
2654
+ result
2655
+ };
2656
+ });
2657
+ }
2658
+ /** How many writes wait in the outbox, as queue.json says. */
2659
+ function queuedCount(root) {
2660
+ const file = syncPaths(root).queue;
2661
+ if (!existsSync(file)) return 0;
2662
+ try {
2663
+ return JSON.parse(readFileSync(file, "utf8")).entries?.length ?? 0;
2664
+ } catch {
2665
+ return 0;
2666
+ }
2667
+ }
2668
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2669
+ /**
2670
+ * Ask the sync client what became of entry `id`, giving it up to `timeoutMs`. A sync client that is still starting is
2671
+ * waited for. `waiting` when it is offline, or the time ran out first.
2672
+ */
2673
+ async function awaitOutcome(root, id, timeoutMs) {
2674
+ const deadline = Date.now() + timeoutMs;
2675
+ for (;;) {
2676
+ const left = deadline - Date.now();
2677
+ if (left <= 0) return { state: "waiting" };
2678
+ const daemon = readDaemon(root);
2679
+ if (daemon) try {
2680
+ return await callDaemon(daemon, "/wait", {
2681
+ id,
2682
+ timeoutMs: left
2683
+ }, left + 1e3);
2684
+ } catch (error) {
2685
+ if (!(error instanceof DaemonUnavailable)) throw error;
2686
+ }
2687
+ await sleep(Math.min(100, left));
2688
+ }
2689
+ }
2690
+ //#endregion
2691
+ //#region src/remote/start.ts
2692
+ /** How long a sync client started in the background may take to claim the hub, its first request to the server included. */
2693
+ const STARTING_MS = 6e4;
2694
+ const alive = (pid) => {
2695
+ try {
2696
+ process.kill(pid, 0);
2697
+ return true;
2698
+ } catch {
2699
+ return false;
2700
+ }
2701
+ };
2702
+ /**
2703
+ * Make sure the hub at `root` has a running sync client, starting `mainflow sync` in the background when it has
2704
+ * none. `env` gives the sync server and token as the environment variables do; for a hub other than the one this
2705
+ * process was started in it is `{}`, so the process started reads that hub's own .mainflow/local/sync.json.
2706
+ */
2707
+ async function startSyncInBackground(root, env = process.env) {
2708
+ const probe = await probeDaemon(root);
2709
+ if (probe && probe.state !== "gone") return { state: "running" };
2710
+ const paths = syncPaths(root);
2711
+ const cli = process.argv[1];
2712
+ if (process.env.MAINFLOW_NO_BACKGROUND_SYNC === "1") return {
2713
+ state: "failed",
2714
+ reason: "starting it in the background is turned off by MAINFLOW_NO_BACKGROUND_SYNC"
2715
+ };
2716
+ if (!cli) return {
2717
+ state: "failed",
2718
+ reason: "this process cannot tell where the mainflow CLI is"
2719
+ };
2720
+ const log = relative(root, paths.log);
2721
+ return nodeStorage(root).lock(() => {
2722
+ const reused = probe?.state === "gone" && forgetDaemon(root, probe.daemon) ? probe.daemon.pid : void 0;
2723
+ if (reused === void 0 && readDaemon(root)) return { state: "starting" };
2724
+ let previous = null;
2725
+ try {
2726
+ previous = existsSync(paths.started) ? JSON.parse(readFileSync(paths.started, "utf8")) : null;
2727
+ } catch {
2728
+ previous = null;
2729
+ }
2730
+ const since = typeof previous?.at === "string" ? Date.now() - Date.parse(previous.at) : Infinity;
2731
+ if (reused === void 0 && typeof previous?.pid === "number" && alive(previous.pid) && since < STARTING_MS) return { state: "starting" };
2732
+ mkdirSync(paths.own, { recursive: true });
2733
+ const out = openSync(paths.log, "a");
2734
+ let pid;
2735
+ try {
2736
+ const child = spawn(process.execPath, [cli, "sync"], {
2737
+ cwd: root,
2738
+ detached: true,
2739
+ stdio: [
2740
+ "ignore",
2741
+ out,
2742
+ out
2743
+ ],
2744
+ env: {
2745
+ ...process.env,
2746
+ [ENV.url]: env[ENV.url] ?? "",
2747
+ [ENV.token]: env[ENV.token] ?? ""
2748
+ }
2749
+ });
2750
+ child.on("error", () => {});
2751
+ child.unref();
2752
+ pid = child.pid;
2753
+ } finally {
2754
+ closeSync(out);
2755
+ }
2756
+ if (pid === void 0) return {
2757
+ state: "failed",
2758
+ reason: `could not start mainflow sync; see ${log}`
2759
+ };
2760
+ writeFileSync(paths.started, JSON.stringify({
2761
+ pid,
2762
+ at: (/* @__PURE__ */ new Date()).toISOString()
2763
+ }));
2764
+ if (reused !== void 0) return {
2765
+ state: "restarted",
2766
+ reason: `.mainflow/local/sync/daemon.json named pid ${reused}, which is not mainflow sync; started mainflow sync`
2767
+ };
2768
+ if (previous) return {
2769
+ state: "restarted",
2770
+ reason: `the last mainflow sync started for this hub stopped (see ${log}); started it again`
2771
+ };
2772
+ return { state: "started" };
2773
+ });
2774
+ }
2775
+ //#endregion
2776
+ //#region src/remote/operation.ts
2777
+ /** Added to a write the local files refused, unless the sync client runs with the server's latest state. */
2778
+ const STALE_COPY = "the local copy may not have caught up with the sync server yet; retry once mainflow sync has caught up (mainflow sync status)";
2779
+ /** Whether the hub's sync client runs and has the server's latest state, asked with a short wait. */
2780
+ async function caughtUp(root) {
2781
+ const daemon = readDaemon(root);
2782
+ if (!daemon) return false;
2783
+ try {
2784
+ return (await callDaemon(daemon, "/status", void 0, 2e3)).caughtUp === true;
2785
+ } catch {
2786
+ return false;
2787
+ }
2788
+ }
2789
+ /** How long a writing command waits for the server's answer before it ends with its write in the outbox. */
2790
+ const CONFIRM_MS = 15e3;
2791
+ const QUEUED_LINE = "(queued: written here and waiting in the outbox; mainflow sync sends it once connected, and the sync server may give new entries other ids)";
2792
+ /** The lines a command prints after a write's value; a line starting with "!" is a warning. */
2793
+ function writtenLines(written) {
2794
+ return [
2795
+ ...written.notices.map((notice) => `! ${notice.message}`),
2796
+ ...written.renumbered.map((r) => `! the sync server gave ${r.from} to another entry; this one is ${r.to}`),
2797
+ ...written.restarted ? [`! ${written.restarted}`] : [],
2798
+ ...written.stopped ? [`! ${written.stopped}`] : [],
2799
+ ...written.queued ? [QUEUED_LINE] : []
2800
+ ];
2801
+ }
2802
+ /**
2803
+ * The writer for the hub at `root`. `env` gives the sync server and token as the environment variables do; a hub
2804
+ * other than the one this process was started in, such as another hub the interface shows, passes `{}`, so only its
2805
+ * own .mainflow/local/sync.json and sync client count.
2806
+ */
2807
+ function hubWriter(root, env = process.env, options = {}) {
2808
+ const startSync = options.startSync ?? startSyncInBackground;
2809
+ return async (op, input) => {
2810
+ if (!remoteMode(root, env)) return {
2811
+ value: applyOperation(nodeStorage(root), op, input, { now: nowMinute() }).value,
2812
+ queued: false,
2813
+ renumbered: [],
2814
+ notices: []
2815
+ };
2816
+ let queuedOp;
2817
+ try {
2818
+ queuedOp = queueOperation(root, {
2819
+ protocol: 2,
2820
+ op,
2821
+ input,
2822
+ now: requestTime()
2823
+ });
2824
+ } catch (error) {
2825
+ if (refusedHere(error) && !await caughtUp(root)) error.message += `; ${STALE_COPY}`;
2826
+ throw error;
2827
+ }
2828
+ const { id, result } = queuedOp;
2829
+ const start = await startSync(root, env);
2830
+ const queued = {
2831
+ value: result.value,
2832
+ queued: true,
2833
+ renumbered: [],
2834
+ notices: result.notices,
2835
+ ...start.state === "restarted" ? { restarted: start.reason } : {}
2836
+ };
2837
+ if (start.state === "failed") return {
2838
+ ...queued,
2839
+ stopped: `mainflow sync is not running (${start.reason}); the write waits in .mainflow/local/sync/queue.json until it runs`
2840
+ };
2841
+ if (options.confirmMs === void 0) return queued;
2842
+ const outcome = await awaitOutcome(root, id, options.confirmMs);
2843
+ if (outcome.state === "applied") {
2844
+ const { response } = outcome;
2845
+ return {
2846
+ ...queued,
2847
+ value: response.value,
2848
+ queued: false,
2849
+ renumbered: response.renumbered,
2850
+ notices: response.notices
2851
+ };
2852
+ }
2853
+ if (outcome.state === "refused") {
2854
+ const saved = outcome.saved.length ? `; what it wrote here was saved to ${outcome.saved.join(", ")} and the files have the server's version again` : "";
2855
+ const message = `the sync server refused ${op}: ${outcome.message}${saved}${queued.restarted ? `; ${queued.restarted}` : ""}`;
2856
+ throw outcome.reason === "conflict" ? new ConflictError(message) : new WriteError(message);
2857
+ }
2858
+ return queued;
2859
+ };
2860
+ }
2861
+ //#endregion
2862
+ export { ServerError as C, ServerApi as S, silentHint as _, remoteMode as a, isSynced as b, syncSettings as c, startDaemon as d, DaemonRunning as f, readDaemon as g, probeDaemon as h, queuedCount as i, hookAnswer as l, callDaemon as m, hubWriter as n, ENV as o, DaemonUnavailable as p, writtenLines as r, hubName as s, CONFIRM_MS as t, planFlush as u, SETTINGS_FILE as v, syncPaths as x, SYNC_DIR as y };