mainflow 0.41.0 → 0.43.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +755 -155
- package/dist/{dist-zSabglPq.js → dist-D5rZ0MQH.js} +71 -23
- package/dist/facts.d.ts +61 -12
- package/dist/facts.js +56 -13
- package/dist/grant-commands.d.ts +1 -1
- package/dist/grant-commands.js +1 -1
- package/dist/hooks.d.ts +5 -540
- package/dist/hooks.js +126 -92
- package/dist/{host-BAMZmRy8.js → host-BJvqZy-i.js} +1374 -489
- package/dist/host.d.ts +3 -2
- package/dist/host.js +1 -1
- package/dist/{index-Dk4fCI7q.d.ts → index-BqPQp6TF.d.ts} +2 -8
- package/dist/index-DRpamPge.d.ts +550 -0
- package/dist/launch.d.ts +6 -5
- package/dist/launch.js +323 -1
- package/dist/native/mainflow-swap-darwin-arm64 +0 -0
- package/dist/native/mainflow-swap-darwin-x64 +0 -0
- package/dist/{operation-DlR1P_Pt.js → operation-BhBbGauo.js} +610 -343
- package/dist/{ui-9uuI4Jif.js → ui-xxzmhAqF.js} +107 -98
- package/dist/version-Dvv4jwF1.js +6 -0
- package/dist/web/assets/{index-DvYj_6IC.js → index-B4w2iRCQ.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +2 -2
- package/dist/launch-CMeKJsNd.js +0 -327
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { $t as WriteError, B as applyOffline, Bt as registeredName, Ct as readsAsText, G as scopeOfPath, Gt as nodeStorage, H as renumberFiles, J as syncedDir, K as hubDigest, Ot as hubRequirements, St as exceedsFileLimit, Tt as textBytes, U as renumberPath, V as renumberRequest, W as renumberText, X as applyOperation, Y as syncedPath, Zt as ConflictError, _t as MAX_FILE_BYTES, bt as VERSION_HEADER, en as contentHash, ht as MAX_BLOB_BYTES, kt as versionMismatch, mt as BLOB_HASH, pt as ACCESS_HEADERS, q as mayHoldSynced, tn as splitFrontMatter, ut as nowMinute, vt as ROUTES, wt as requestTime, xt as comparePaths, yt as SOCKET_MAX_AGE_CLOSE } from "./host-BJvqZy-i.js";
|
|
2
|
+
import { t as CLI_VERSION } from "./version-Dvv4jwF1.js";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, createWriteStream, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
4
|
+
import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, createWriteStream, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, watch, writeFileSync } from "node:fs";
|
|
4
5
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
@@ -15,14 +16,13 @@ import { EventEmitter } from "node:events";
|
|
|
15
16
|
import { COPYFILE_EXCL } from "node:constants";
|
|
16
17
|
import { appendFile, mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
17
18
|
import { diff3Merge } from "node-diff3";
|
|
18
|
-
import ignore from "ignore";
|
|
19
19
|
//#region ../sync/dist/paths.js
|
|
20
20
|
/** The sync client's own directory, inside local/ so it is neither synced nor committed. */
|
|
21
21
|
const SYNC_DIR = "local/sync";
|
|
22
22
|
/** The settings a machine keeps instead of the environment variables: { url, clientId, clientSecret }. Never synced or committed. */
|
|
23
23
|
const SETTINGS_FILE = "local/sync.json";
|
|
24
|
-
const syncPaths = (
|
|
25
|
-
const data =
|
|
24
|
+
const syncPaths = (dirs) => {
|
|
25
|
+
const data = dirs.data;
|
|
26
26
|
const own = join(data, SYNC_DIR);
|
|
27
27
|
return {
|
|
28
28
|
data,
|
|
@@ -50,27 +50,8 @@ const syncPaths = (root) => {
|
|
|
50
50
|
log: join(own, "sync.log")
|
|
51
51
|
};
|
|
52
52
|
};
|
|
53
|
-
/**
|
|
54
|
-
const
|
|
55
|
-
/\.tmp$/,
|
|
56
|
-
/\.sw[a-p]$/,
|
|
57
|
-
/\.swx$/,
|
|
58
|
-
/~$/,
|
|
59
|
-
/^\.#/,
|
|
60
|
-
/^4913$/,
|
|
61
|
-
/\.crswap$/,
|
|
62
|
-
/^\.DS_Store$/
|
|
63
|
-
];
|
|
64
|
-
/** Whether a file at `path` (relative to .mainflow/) is synced. */
|
|
65
|
-
function isSynced(path) {
|
|
66
|
-
const parts = path.split("/");
|
|
67
|
-
if (parts.some((part) => part === "" || part === "." || part === "..")) return false;
|
|
68
|
-
if (parts[0] === "local" && !(parts[1] === "sessions" && parts.length === 3)) return false;
|
|
69
|
-
const name = parts.at(-1);
|
|
70
|
-
return !TRANSIENT.some((pattern) => pattern.test(name));
|
|
71
|
-
}
|
|
72
|
-
/** Whether the directory at `path` may hold synced files, so a scan walks into it. */
|
|
73
|
-
const mayHoldSynced = (path) => path !== "local" ? !path.startsWith("local/") || path === "local/sessions" : true;
|
|
53
|
+
/** Whether a file at `path` (relative to .mainflow/) is synced (syncedPath in the core package). */
|
|
54
|
+
const isSynced = syncedPath;
|
|
74
55
|
/**
|
|
75
56
|
* Whether the client's file watcher watches inside the directory at `path`: one that may hold synced files, and
|
|
76
57
|
* local/sync/ itself for the outbox; the directories under local/sync/ hold only the client's own files.
|
|
@@ -89,22 +70,30 @@ const ENV = {
|
|
|
89
70
|
clientSecret: "MAINFLOW_SYNC_CLIENT_SECRET",
|
|
90
71
|
onDemand: "MAINFLOW_SYNC_ON_DEMAND"
|
|
91
72
|
};
|
|
92
|
-
/** The values of MAINFLOW_SYNC_ON_DEMAND that
|
|
73
|
+
/** The values of MAINFLOW_SYNC_ON_DEMAND that keep it on; any other value that is not empty (such as 0) turns it off. */
|
|
93
74
|
const ON = /* @__PURE__ */ new Set([
|
|
94
75
|
"1",
|
|
95
76
|
"true",
|
|
96
77
|
"on"
|
|
97
78
|
]);
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The hub's name as config.yaml gives it. A hub whose config.yaml is not downloaded yet, as in a new environment before
|
|
81
|
+
* its first `mainflow sync`, has the name it was registered under.
|
|
82
|
+
*/
|
|
83
|
+
function hubName(dirs, env = process.env) {
|
|
84
|
+
const file = join(dirs.data, "config.yaml");
|
|
85
|
+
if (!existsSync(file)) {
|
|
86
|
+
const registered = registeredName(dirs, env);
|
|
87
|
+
if (registered) return registered;
|
|
88
|
+
}
|
|
89
|
+
const config = parse$1(readFileSync(file, "utf8"));
|
|
90
|
+
if (typeof config?.hub !== "string") throw new Error(`${file} names no hub`);
|
|
102
91
|
return config.hub;
|
|
103
92
|
}
|
|
104
93
|
const text = (value) => typeof value === "string" ? value : "";
|
|
105
94
|
/** What .mainflow/local/sync.json holds, nothing when there is no such file. */
|
|
106
|
-
function stored(
|
|
107
|
-
const file = join(
|
|
95
|
+
function stored(dirs) {
|
|
96
|
+
const file = join(dirs.data, SETTINGS_FILE);
|
|
108
97
|
if (!existsSync(file)) return {};
|
|
109
98
|
try {
|
|
110
99
|
return JSON.parse(readFileSync(file, "utf8")) ?? {};
|
|
@@ -115,20 +104,20 @@ function stored(root) {
|
|
|
115
104
|
/**
|
|
116
105
|
* The sync settings of the hub at `root`, or null when either half of the token is missing. Each value comes from its
|
|
117
106
|
* environment variable, or from sync.json when the variable is unset or empty; the server is DEFAULT_SYNC_URL when
|
|
118
|
-
* neither names one.
|
|
107
|
+
* neither names one, and on-demand download is on unless one of them sets it off.
|
|
119
108
|
*/
|
|
120
|
-
function syncSettings(
|
|
121
|
-
const saved = stored(
|
|
109
|
+
function syncSettings(dirs, env = process.env) {
|
|
110
|
+
const saved = stored(dirs);
|
|
122
111
|
const url = env[ENV.url] || text(saved.url) || "https://mainflow-sync.carlos209310.space";
|
|
123
112
|
const clientId = env[ENV.clientId] || text(saved.clientId);
|
|
124
113
|
const clientSecret = env[ENV.clientSecret] || text(saved.clientSecret);
|
|
125
114
|
if (!clientId || !clientSecret) return null;
|
|
126
|
-
const onDemand = env[ENV.onDemand] ? ON.has(env[ENV.onDemand].trim().toLowerCase()) : saved.onDemand
|
|
115
|
+
const onDemand = env[ENV.onDemand] ? ON.has(env[ENV.onDemand].trim().toLowerCase()) : saved.onDemand !== false;
|
|
127
116
|
return {
|
|
128
117
|
url: url.replace(/\/+$/, ""),
|
|
129
118
|
clientId,
|
|
130
119
|
clientSecret,
|
|
131
|
-
hub: hubName(
|
|
120
|
+
hub: hubName(dirs, env),
|
|
132
121
|
onDemand
|
|
133
122
|
};
|
|
134
123
|
}
|
|
@@ -142,9 +131,9 @@ const EARLIER_TOKEN = {
|
|
|
142
131
|
* nothing reads now: where that token is and which two values to set instead, never the token itself. Null when the
|
|
143
132
|
* settings are whole or no earlier token is kept, so a hub that never synced writes to its local files without a word.
|
|
144
133
|
*/
|
|
145
|
-
function earlierToken(
|
|
146
|
-
if (syncSettings(
|
|
147
|
-
const where = [...env[EARLIER_TOKEN.env] ? [EARLIER_TOKEN.env] : [], ...text(stored(
|
|
134
|
+
function earlierToken(dirs, env = process.env) {
|
|
135
|
+
if (syncSettings(dirs, env)) return null;
|
|
136
|
+
const where = [...env[EARLIER_TOKEN.env] ? [EARLIER_TOKEN.env] : [], ...text(stored(dirs)[EARLIER_TOKEN.key]) ? [`the ${EARLIER_TOKEN.key} key of .mainflow/${SETTINGS_FILE}`] : []];
|
|
148
137
|
if (where.length === 0) return null;
|
|
149
138
|
return `${where.join(" and ")} ${where.length > 1 ? "are" : "is"} no longer read, so mainflow does not write through the sync server; set ${ENV.clientId} and ${ENV.clientSecret}, or clientId and clientSecret in .mainflow/${SETTINGS_FILE}, to a Cloudflare Access service token`;
|
|
150
139
|
}
|
|
@@ -172,6 +161,32 @@ var Unreachable = class extends Error {
|
|
|
172
161
|
this.broken = broken;
|
|
173
162
|
}
|
|
174
163
|
};
|
|
164
|
+
/**
|
|
165
|
+
* The hub takes writes only from other mainflow versions than this CLI's (`client-outdated`): nothing of the request was
|
|
166
|
+
* applied or kept. Unreachable as any 5xx, so a write stays in the outbox and goes again after `retryAfter` seconds; the
|
|
167
|
+
* message says which versions the hub takes and the command that installs one.
|
|
168
|
+
*/
|
|
169
|
+
var ClientOutdatedError = class extends Unreachable {
|
|
170
|
+
retryAfter;
|
|
171
|
+
constructor(message, retryAfter) {
|
|
172
|
+
super(message, false);
|
|
173
|
+
this.retryAfter = retryAfter;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* The hub is being migrated or restored in batches, or such a rewrite stopped partway (`hub-rewriting`): nothing of the
|
|
178
|
+
* request was applied or kept. Unreachable as any 5xx, so a write stays in the outbox and goes again; `rewrite` is the
|
|
179
|
+
* rewrite's state, for a command that waits for it or says what holds the hub.
|
|
180
|
+
*/
|
|
181
|
+
var HubRewritingError = class extends Unreachable {
|
|
182
|
+
rewrite;
|
|
183
|
+
retryAfter;
|
|
184
|
+
constructor(message, rewrite, retryAfter) {
|
|
185
|
+
super(message, false);
|
|
186
|
+
this.rewrite = rewrite;
|
|
187
|
+
this.retryAfter = retryAfter;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
175
190
|
/** The server refused before the hub (400, 404) or failed (500), or Cloudflare Access in front of it refused (AccessRefused). */
|
|
176
191
|
var ServerError = class extends Error {
|
|
177
192
|
status;
|
|
@@ -261,7 +276,8 @@ var ServerApi = class {
|
|
|
261
276
|
wire: 0,
|
|
262
277
|
body: 0,
|
|
263
278
|
sent: 0,
|
|
264
|
-
listed: 0
|
|
279
|
+
listed: 0,
|
|
280
|
+
unchanged: 0
|
|
265
281
|
};
|
|
266
282
|
constructor(settings, timeoutMs = 3e4) {
|
|
267
283
|
this.settings = settings;
|
|
@@ -283,11 +299,15 @@ var ServerApi = class {
|
|
|
283
299
|
maxFreeSockets: 1
|
|
284
300
|
});
|
|
285
301
|
}
|
|
286
|
-
/**
|
|
302
|
+
/**
|
|
303
|
+
* The service token's headers, and the CLI's version when the settings name it (VERSION_HEADER), which every request
|
|
304
|
+
* and the WebSocket's upgrade carry. Never logged.
|
|
305
|
+
*/
|
|
287
306
|
get access() {
|
|
288
307
|
return {
|
|
289
308
|
[ACCESS_HEADERS.clientId]: this.settings.clientId,
|
|
290
|
-
[ACCESS_HEADERS.clientSecret]: this.settings.clientSecret
|
|
309
|
+
[ACCESS_HEADERS.clientSecret]: this.settings.clientSecret,
|
|
310
|
+
...this.settings.version === void 0 ? {} : { [VERSION_HEADER]: this.settings.version }
|
|
291
311
|
};
|
|
292
312
|
}
|
|
293
313
|
/** A failure to reach the server or of the connection, as the Unreachable it is. */
|
|
@@ -319,8 +339,13 @@ var ServerApi = class {
|
|
|
319
339
|
} catch {
|
|
320
340
|
throw new Unreachable(`the sync server answered ${answer.status} without a readable body`, true);
|
|
321
341
|
}
|
|
322
|
-
if (answer.status >= 500) throw new Unreachable(`the sync server failed (${answer.status})`, true);
|
|
323
342
|
const refused = parsed;
|
|
343
|
+
if (answer.status === 503 && refused.reason === "client-outdated") throw new ClientOutdatedError(`the sync server refused the write: ${refused.message ?? "this mainflow version may not write to the hub"}`, Math.max(1, Number(answer.retryAfter) || 60));
|
|
344
|
+
if (answer.status === 503 && refused.reason === "hub-rewriting") {
|
|
345
|
+
const rewriting = parsed;
|
|
346
|
+
throw new HubRewritingError(rewriting.message, rewriting.rewrite, Math.max(1, Number(answer.retryAfter) || 10));
|
|
347
|
+
}
|
|
348
|
+
if (answer.status >= 500) throw new Unreachable(`the sync server failed (${answer.status})`, true);
|
|
324
349
|
if (answer.status === 404 || refused.reason === "malformed") throw new ServerError(`the sync server refused: ${refused.message ?? answer.status}`, answer.status);
|
|
325
350
|
return {
|
|
326
351
|
status: answer.status,
|
|
@@ -543,6 +568,7 @@ var ServerApi = class {
|
|
|
543
568
|
this.transfer.batches++;
|
|
544
569
|
this.transfer.files += body.files.filter((entry) => entry.kind === "file").length;
|
|
545
570
|
this.transfer.listed += body.files.filter((entry) => entry.kind === "listed").length;
|
|
571
|
+
if (body.unchanged === true) this.transfer.unchanged++;
|
|
546
572
|
this.transfer.wire += wire;
|
|
547
573
|
this.transfer.body += size;
|
|
548
574
|
this.transfer.sent += sent;
|
|
@@ -558,6 +584,9 @@ var ServerApi = class {
|
|
|
558
584
|
* `listOnly` asks for the files under those prefixes as `listed`, without their text. A part of the hub alone is taken
|
|
559
585
|
* from `after` on, and ends with the batch that covers `through`; only the paths of that range are told of. `blobs` takes
|
|
560
586
|
* the binary files too, each that differs from `have` as `blob`; `have` names a binary file by its sha256 then.
|
|
587
|
+
* `digest` names the digest of `have` and the directories the client holds (hubDigest), asked on the first batch of a
|
|
588
|
+
* whole hub's snapshot with `blobs`: a server whose hub it is the digest of answers that nothing differs, and the
|
|
589
|
+
* snapshot is that one batch, with the client's directories as the entries, as the server would have listed them.
|
|
561
590
|
*/
|
|
562
591
|
async snapshot(have = /* @__PURE__ */ new Map(), options = {}) {
|
|
563
592
|
const paths = [...have.keys()].sort(comparePaths);
|
|
@@ -569,14 +598,23 @@ var ServerApi = class {
|
|
|
569
598
|
for (;;) {
|
|
570
599
|
while (after !== void 0 && start < paths.length && comparePaths(paths[start], after) <= 0) start++;
|
|
571
600
|
const range = Object.fromEntries(paths.slice(start).map((path) => [path, have.get(path)]));
|
|
572
|
-
const
|
|
601
|
+
const asking = options.digest !== void 0 && options.blobs === true && after === void 0;
|
|
602
|
+
const { seq, files, next, unchanged } = await this.batch({
|
|
573
603
|
protocol: 3,
|
|
574
604
|
have: range,
|
|
575
605
|
unknown: true,
|
|
576
606
|
...after === void 0 ? {} : { after },
|
|
607
|
+
...asking ? { digest: options.digest.value } : {},
|
|
577
608
|
...options.listOnly?.length ? { listOnly: options.listOnly } : {},
|
|
578
609
|
...options.blobs ? { blobs: true } : {}
|
|
579
610
|
});
|
|
611
|
+
if (unchanged === true && asking) return [{
|
|
612
|
+
seq,
|
|
613
|
+
files: options.digest.dirs.map((path) => ({
|
|
614
|
+
kind: "dir",
|
|
615
|
+
path
|
|
616
|
+
}))
|
|
617
|
+
}];
|
|
580
618
|
if (parts.length > 0 && seq < parts.at(-1).seq) break;
|
|
581
619
|
parts.push({
|
|
582
620
|
seq,
|
|
@@ -613,6 +651,53 @@ var ServerApi = class {
|
|
|
613
651
|
return body;
|
|
614
652
|
}
|
|
615
653
|
/**
|
|
654
|
+
* A page of the hub's change log (ROUTES.changes), newest first. A server from before the log has no such route: a
|
|
655
|
+
* ServerError of status 404, as is any refusal.
|
|
656
|
+
*/
|
|
657
|
+
async changes(request = {}) {
|
|
658
|
+
const { body } = await this.call("POST", ROUTES.changes(this.settings.hub), {
|
|
659
|
+
protocol: 3,
|
|
660
|
+
...request
|
|
661
|
+
}, "read");
|
|
662
|
+
if (!body.ok) throw new ServerError(`the sync server refused the change log: ${body.message}`, body.status);
|
|
663
|
+
return body;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Put the hub, or a path of it, back to a seq or a time (ROUTES.restore): the hub's answer, applied, set off in batches
|
|
667
|
+
* (`started`), or refused. It goes on a connection of its own, as an upload does, and is never sent again, since it has
|
|
668
|
+
* no requestId: one sent again after its answer was lost finds the hub as it left it only when nothing was written in
|
|
669
|
+
* between, and else puts that writing back too. A server from before the log has no such route: a ServerError of status
|
|
670
|
+
* 404. A hub being rewritten throws HubRewritingError.
|
|
671
|
+
*/
|
|
672
|
+
async restore(request) {
|
|
673
|
+
return (await this.call("POST", ROUTES.restore(this.settings.hub), {
|
|
674
|
+
protocol: 3,
|
|
675
|
+
...request
|
|
676
|
+
}, "fresh")).body;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Convert the hub to the server's data format on the server (ROUTES.migrate): the hub's answer, applied, set off in
|
|
680
|
+
* batches (`started`), or refused. It goes on a connection of its own and is never sent again, as a restore. A server
|
|
681
|
+
* from before the route answers 404 from its router: a ServerError of status 404. A hub being rewritten throws
|
|
682
|
+
* HubRewritingError.
|
|
683
|
+
*/
|
|
684
|
+
async migrate(request = {}) {
|
|
685
|
+
return (await this.call("POST", ROUTES.migrate(this.settings.hub), {
|
|
686
|
+
protocol: 3,
|
|
687
|
+
...request
|
|
688
|
+
}, "fresh")).body;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* The migration or restore the server runs on the hub in batches, or ran last (ROUTES.rewrite). It goes on a connection
|
|
692
|
+
* of the reads, so one the server closed meanwhile is asked again. A server from before batches has no such route: a
|
|
693
|
+
* ServerError of status 404.
|
|
694
|
+
*/
|
|
695
|
+
async rewrite() {
|
|
696
|
+
const { body } = await this.call("POST", ROUTES.rewrite(this.settings.hub), { protocol: 3 }, "read");
|
|
697
|
+
if (!body.ok) throw new ServerError(`the sync server refused to tell the hub's rewrite: ${body.message}`, body.status);
|
|
698
|
+
return body;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
616
701
|
* Open the hub's broadcast connection, taking the binary files too (ROUTES.socket). It opens before the client knows
|
|
617
702
|
* whether the server keeps binary files (blobRoute), and a server from before them reads nothing of `blobs`.
|
|
618
703
|
*/
|
|
@@ -764,135 +849,6 @@ function mergeThreeWay(current, base, incoming) {
|
|
|
764
849
|
};
|
|
765
850
|
}
|
|
766
851
|
//#endregion
|
|
767
|
-
//#region ../sync/dist/scope.js
|
|
768
|
-
/** The name of a file of ignore rules. */
|
|
769
|
-
const GITIGNORE = ".gitignore";
|
|
770
|
-
const decoder$1 = new TextDecoder("utf-8", { fatal: true });
|
|
771
|
-
/** The text of the file at `file`, or null when there is none or it cannot be read. */
|
|
772
|
-
function readText(file) {
|
|
773
|
-
try {
|
|
774
|
-
return readFileSync(file, "utf8");
|
|
775
|
-
} catch {
|
|
776
|
-
return null;
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
/** The hub's ignore rules, by the directory of each .gitignore, as last read. */
|
|
780
|
-
var SyncScope = class {
|
|
781
|
-
/** Each .gitignore's text and rules by its directory, relative to .mainflow/ with `/`, '' for .mainflow/ itself */
|
|
782
|
-
rules = /* @__PURE__ */ new Map();
|
|
783
|
-
/** Whether each path, a directory's ending in `/`, is ignored under the rules as they are */
|
|
784
|
-
decided = /* @__PURE__ */ new Map();
|
|
785
|
-
changes = 0;
|
|
786
|
-
/** How many times the rules changed: a caller that noted it tells whether they changed since. */
|
|
787
|
-
get generation() {
|
|
788
|
-
return this.changes;
|
|
789
|
-
}
|
|
790
|
-
/** The text of `dir`'s .gitignore as the rules have it, null for none. */
|
|
791
|
-
text(dir) {
|
|
792
|
-
return this.rules.get(dir)?.text ?? null;
|
|
793
|
-
}
|
|
794
|
-
/** Take `text` as the .gitignore of `dir`, null for none; true when that changes the rules. */
|
|
795
|
-
set(dir, text) {
|
|
796
|
-
if ((this.rules.get(dir)?.text ?? null) === text) return false;
|
|
797
|
-
if (text === null) this.rules.delete(dir);
|
|
798
|
-
else this.rules.set(dir, {
|
|
799
|
-
text,
|
|
800
|
-
matcher: ignore().add(text)
|
|
801
|
-
});
|
|
802
|
-
this.decided.clear();
|
|
803
|
-
this.changes++;
|
|
804
|
-
return true;
|
|
805
|
-
}
|
|
806
|
-
/** Forget the rules of every directory but `dirs`, as when a scan of the whole hub no longer finds the others. */
|
|
807
|
-
keepOnly(dirs) {
|
|
808
|
-
for (const dir of this.rules.keys()) if (!dirs.has(dir)) this.set(dir, null);
|
|
809
|
-
}
|
|
810
|
-
/** Whether the file at `path`, relative to .mainflow/, is synced: by the sync client's own rules, and not ignored. */
|
|
811
|
-
synced(path) {
|
|
812
|
-
return isSynced(path) && !this.ignored(path, false);
|
|
813
|
-
}
|
|
814
|
-
/** Whether a scan walks into the directory at `path`: one that may hold synced files, and not ignored. */
|
|
815
|
-
walks(path) {
|
|
816
|
-
return mayHoldSynced(path) && !this.ignored(path, true);
|
|
817
|
-
}
|
|
818
|
-
/** Whether git leaves the file or directory (`dir`) at `path` out: it, or a directory above it, is ignored. */
|
|
819
|
-
ignored(path, dir) {
|
|
820
|
-
if (path === "local" || path.startsWith("local/")) return false;
|
|
821
|
-
const parts = path.split("/");
|
|
822
|
-
if (parts.some((part) => part === "" || part === "." || part === "..")) return true;
|
|
823
|
-
const key = dir ? `${path}/` : path;
|
|
824
|
-
const known = this.decided.get(key);
|
|
825
|
-
if (known !== void 0) return known;
|
|
826
|
-
let out = false;
|
|
827
|
-
for (let n = 1; n <= parts.length && !out; n++) out = this.matches(parts, n, n < parts.length || dir);
|
|
828
|
-
this.decided.set(key, out);
|
|
829
|
-
return out;
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* Whether the rules ignore the first `n` parts of a path, a directory's when `dir`: each .gitignore above it in turn,
|
|
833
|
-
* from .mainflow/ down, the last one with a pattern that matches deciding.
|
|
834
|
-
*/
|
|
835
|
-
matches(parts, n, dir) {
|
|
836
|
-
if (parts[n - 1] === ".git") return true;
|
|
837
|
-
let out = false;
|
|
838
|
-
for (let depth = 0; depth < n; depth++) {
|
|
839
|
-
const rules = this.rules.get(parts.slice(0, depth).join("/"));
|
|
840
|
-
if (!rules) continue;
|
|
841
|
-
const test = rules.matcher.test(`${parts.slice(depth, n).join("/")}${dir ? "/" : ""}`);
|
|
842
|
-
if (test.ignored) out = true;
|
|
843
|
-
else if (test.unignored) out = false;
|
|
844
|
-
}
|
|
845
|
-
return out;
|
|
846
|
-
}
|
|
847
|
-
/**
|
|
848
|
-
* Read every .gitignore under `data`, the hub's .mainflow/, again, and return the synced files there; it walks only into
|
|
849
|
-
* the directories the rules leave in, as git does. Synchronous, for a start and for `mainflow check`.
|
|
850
|
-
*/
|
|
851
|
-
load(data) {
|
|
852
|
-
const files = [];
|
|
853
|
-
const dirs = /* @__PURE__ */ new Set();
|
|
854
|
-
const visit = (dir) => {
|
|
855
|
-
let entries;
|
|
856
|
-
try {
|
|
857
|
-
entries = readdirSync(join(data, dir), { withFileTypes: true });
|
|
858
|
-
} catch {
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
dirs.add(dir);
|
|
862
|
-
this.set(dir, entries.some((entry) => entry.name === ".gitignore" && entry.isFile()) ? readText(join(data, dir, GITIGNORE)) : null);
|
|
863
|
-
for (const entry of entries) {
|
|
864
|
-
const path = dir ? `${dir}/${entry.name}` : entry.name;
|
|
865
|
-
if (entry.isDirectory()) {
|
|
866
|
-
if (this.walks(path)) visit(path);
|
|
867
|
-
} else if (entry.isFile() && this.synced(path)) files.push(path);
|
|
868
|
-
}
|
|
869
|
-
};
|
|
870
|
-
visit("");
|
|
871
|
-
this.keepOnly(dirs);
|
|
872
|
-
return files;
|
|
873
|
-
}
|
|
874
|
-
};
|
|
875
|
-
/**
|
|
876
|
-
* The synced files of the hub at `root` larger than the sync server keeps (MAX_FILE_BYTES): UTF-8 text the hub's
|
|
877
|
-
* .gitignore files leave in. The sync client leaves them out of sync; `mainflow check` warns of them.
|
|
878
|
-
*/
|
|
879
|
-
function oversizedFiles(root) {
|
|
880
|
-
const data = join(root, DATA_DIR);
|
|
881
|
-
const out = [];
|
|
882
|
-
for (const path of new SyncScope().load(data)) {
|
|
883
|
-
const file = join(data, path);
|
|
884
|
-
try {
|
|
885
|
-
if (statSync(file).size <= 19e5) continue;
|
|
886
|
-
const bytes = textBytes(decoder$1.decode(readFileSync(file)));
|
|
887
|
-
if (bytes > 19e5) out.push({
|
|
888
|
-
path,
|
|
889
|
-
bytes
|
|
890
|
-
});
|
|
891
|
-
} catch {}
|
|
892
|
-
}
|
|
893
|
-
return out;
|
|
894
|
-
}
|
|
895
|
-
//#endregion
|
|
896
852
|
//#region ../sync/dist/state.js
|
|
897
853
|
/** The requestId an outbox entry goes under: its id, with the number of the try for a binary file's write sent again. */
|
|
898
854
|
const requestIdOf = (entry) => entry.kind === "blob" && entry.retry ? `${entry.id}-${entry.retry}` : entry.id;
|
|
@@ -900,7 +856,8 @@ const requestIdOf = (entry) => entry.kind === "blob" && entry.retry ? `${entry.i
|
|
|
900
856
|
const waiting = (entry) => entry.refused === void 0;
|
|
901
857
|
/** A new entry's id. */
|
|
902
858
|
const entryId = () => randomBytes(8).toString("hex");
|
|
903
|
-
|
|
859
|
+
/** The files an entry writes; none for a directory's removal. */
|
|
860
|
+
const entryPaths = (entry) => entry.kind === "op" ? Object.keys(entry.written) : entry.kind === "rmdir" ? [] : [entry.path];
|
|
904
861
|
/** A local minute without an offset, the `now` of protocol 1 */
|
|
905
862
|
const LOCAL_MINUTE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/;
|
|
906
863
|
/** Protocols whose write requests read the same as the current one's: protocol 3 changed only how a snapshot is taken. */
|
|
@@ -953,13 +910,18 @@ function writeJson(file, value) {
|
|
|
953
910
|
writeFileSync(temp, JSON.stringify(value, null, 1));
|
|
954
911
|
renameSync(temp, file);
|
|
955
912
|
}
|
|
956
|
-
const readKnown = (
|
|
913
|
+
const readKnown = (dirs) => readJson(syncPaths(dirs).known, {}).files ?? {};
|
|
914
|
+
/** The directories known.json names; none from a client before it kept them. */
|
|
915
|
+
const readKnownDirs = (hub) => {
|
|
916
|
+
const dirs = readJson(syncPaths(hub).known, {}).dirs;
|
|
917
|
+
return Array.isArray(dirs) ? dirs.filter((dir) => typeof dir === "string") : [];
|
|
918
|
+
};
|
|
957
919
|
/** What known.json writes before a binary file's sha256, apart from a text's content hash. */
|
|
958
920
|
const BLOB_PREFIX = "blob:";
|
|
959
921
|
/** The text of the version known.json names by `hash`; undefined when it was not kept, as by a sync client before known/. */
|
|
960
|
-
function readKnownText(
|
|
922
|
+
function readKnownText(dirs, hash) {
|
|
961
923
|
try {
|
|
962
|
-
const text = readFileSync(join(syncPaths(
|
|
924
|
+
const text = readFileSync(join(syncPaths(dirs).knownTexts, hash), "utf8");
|
|
963
925
|
return contentHash(text) === hash ? text : void 0;
|
|
964
926
|
} catch {
|
|
965
927
|
return;
|
|
@@ -970,9 +932,10 @@ function readKnownText(root, hash) {
|
|
|
970
932
|
* there yet; the texts of versions it no longer names are removed. A version whose text the client does not have keeps
|
|
971
933
|
* the text known/ holds, if any. The new texts go first, then known.json, then the removals, so a client killed at any
|
|
972
934
|
* step leaves a known.json whose every text is in known/. A binary file is given as its sha256 with BLOB_PREFIX, and no text.
|
|
935
|
+
* `dirs` are the directories it names.
|
|
973
936
|
*/
|
|
974
|
-
function writeKnown(
|
|
975
|
-
const dir = syncPaths(
|
|
937
|
+
function writeKnown(hub, files, dirs = []) {
|
|
938
|
+
const dir = syncPaths(hub).knownTexts;
|
|
976
939
|
mkdirSync(dir, { recursive: true });
|
|
977
940
|
const named = new Set(Object.values(files).map((file) => file.hash));
|
|
978
941
|
const held = new Set(readdirSync(dir));
|
|
@@ -982,26 +945,29 @@ function writeKnown(root, files) {
|
|
|
982
945
|
renameSync(join(dir, `${hash}.tmp`), join(dir, hash));
|
|
983
946
|
held.add(hash);
|
|
984
947
|
}
|
|
985
|
-
const known = {
|
|
986
|
-
|
|
948
|
+
const known = {
|
|
949
|
+
files: Object.fromEntries(Object.entries(files).map(([path, file]) => [path, file.hash])),
|
|
950
|
+
...dirs.length > 0 ? { dirs } : {}
|
|
951
|
+
};
|
|
952
|
+
writeJson(syncPaths(hub).known, known);
|
|
987
953
|
for (const name of held) if (!named.has(name)) rmSync(join(dir, name), { force: true });
|
|
988
954
|
}
|
|
989
|
-
const writeQueue = (
|
|
990
|
-
const readRemote = (
|
|
991
|
-
const writeRemote = (
|
|
955
|
+
const writeQueue = (dirs, entries) => writeJson(syncPaths(dirs).queue, { entries });
|
|
956
|
+
const readRemote = (dirs) => readJson(syncPaths(dirs).remote, {}).files ?? {};
|
|
957
|
+
const writeRemote = (dirs, files) => writeJson(syncPaths(dirs).remote, { files });
|
|
992
958
|
/** The queue as queue.json has it; an entry from before entries had ids gets one, written back. Call under the write lock. */
|
|
993
|
-
function readQueue(
|
|
994
|
-
const entries = readJson(syncPaths(
|
|
959
|
+
function readQueue(dirs) {
|
|
960
|
+
const entries = readJson(syncPaths(dirs).queue, {}).entries ?? [];
|
|
995
961
|
if (entries.every((entry) => typeof entry.id === "string")) return entries;
|
|
996
962
|
const named = entries.map((entry) => ({
|
|
997
963
|
...entry,
|
|
998
964
|
id: entry.id ?? entryId()
|
|
999
965
|
}));
|
|
1000
|
-
writeQueue(
|
|
966
|
+
writeQueue(dirs, named);
|
|
1001
967
|
return named;
|
|
1002
968
|
}
|
|
1003
969
|
/** Run `fn` under the hub's write lock, the one every mainflow writer of this machine takes. */
|
|
1004
|
-
const underLock = (
|
|
970
|
+
const underLock = (dirs, fn) => nodeStorage(dirs).lock(fn);
|
|
1005
971
|
//#endregion
|
|
1006
972
|
//#region ../sync/dist/swap-helper.js
|
|
1007
973
|
const HELPER_SOURCE = "mainflow-swap.c";
|
|
@@ -1376,14 +1342,14 @@ const SWAP_PAUSE_MS = 6e4;
|
|
|
1376
1342
|
/** queue.json, relative to .mainflow/: other writers append to it, and the client sends what they appended */
|
|
1377
1343
|
const OUTBOX = `${SYNC_DIR}/queue.json`;
|
|
1378
1344
|
const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1379
|
-
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1345
|
+
const decoder$1 = new TextDecoder("utf-8", { fatal: true });
|
|
1380
1346
|
const sameLocal = (a, b) => a.kind === b.kind && (a.kind !== "text" || a.text === b.text) && (a.kind !== "binary" || a.hash === b.hash);
|
|
1381
1347
|
/** What `bytes` read from a file are: a text when they are UTF-8, a binary file otherwise. */
|
|
1382
1348
|
function contentOf(bytes) {
|
|
1383
1349
|
try {
|
|
1384
1350
|
return {
|
|
1385
1351
|
kind: "text",
|
|
1386
|
-
text: decoder.decode(bytes)
|
|
1352
|
+
text: decoder$1.decode(bytes)
|
|
1387
1353
|
};
|
|
1388
1354
|
} catch {
|
|
1389
1355
|
return {
|
|
@@ -1510,7 +1476,7 @@ function heldBase(entry) {
|
|
|
1510
1476
|
*/
|
|
1511
1477
|
function textAt(entry, path) {
|
|
1512
1478
|
if (entry.kind === "op") return entry.written[path];
|
|
1513
|
-
if (entry.path !== path || entry.kind === "blob") return void 0;
|
|
1479
|
+
if (entry.path !== path || entry.kind === "blob" || entry.kind === "rmdir") return void 0;
|
|
1514
1480
|
if (entry.kind === "remove") return null;
|
|
1515
1481
|
return entry.text;
|
|
1516
1482
|
}
|
|
@@ -1556,7 +1522,7 @@ function holdsFiles(parts, held) {
|
|
|
1556
1522
|
}
|
|
1557
1523
|
var SyncClient = class extends EventEmitter {
|
|
1558
1524
|
opts;
|
|
1559
|
-
|
|
1525
|
+
dirs;
|
|
1560
1526
|
paths;
|
|
1561
1527
|
api;
|
|
1562
1528
|
problems = [];
|
|
@@ -1654,14 +1620,6 @@ var SyncClient = class extends EventEmitter {
|
|
|
1654
1620
|
uploadWanted = false;
|
|
1655
1621
|
/** Why the server refused the upload of this environment's copy, as last reported (snapshotNow); null once it holds the hub */
|
|
1656
1622
|
uploadRefusal = null;
|
|
1657
|
-
/** The hub's .gitignore files, which leave a file out of sync as they leave it out of git (src/scope.ts) */
|
|
1658
|
-
scope = new SyncScope();
|
|
1659
|
-
/**
|
|
1660
|
-
* The generation of the rules (SyncScope.generation) the last snapshot was asked for under. A file the rules left out
|
|
1661
|
-
* then was not taken in, whatever the server sent, nor a broadcast of it since; rules changed after it may let such a
|
|
1662
|
-
* file in, so the snapshot is taken again (walk), and a file not taken in from the server waits for it (scopeStale).
|
|
1663
|
-
*/
|
|
1664
|
-
scopeTaken = -1;
|
|
1665
1623
|
/**
|
|
1666
1624
|
* The paths the server last named `unknown`: it holds no file there and never removed one. A file here at such a path
|
|
1667
1625
|
* is new to the server, whatever known.json says, as when another environment uploaded the hub to a new server
|
|
@@ -1670,6 +1628,15 @@ var SyncClient = class extends EventEmitter {
|
|
|
1670
1628
|
unheld = /* @__PURE__ */ new Set();
|
|
1671
1629
|
/** The hub's seq of each path's version in `cloud`: a version from the server with a seq no higher is not newer */
|
|
1672
1630
|
fileSeq = /* @__PURE__ */ new Map();
|
|
1631
|
+
/** The hub's seq of each directory as the server last named it, held or removed, as fileSeq is of a file */
|
|
1632
|
+
dirSeq = /* @__PURE__ */ new Map();
|
|
1633
|
+
/**
|
|
1634
|
+
* The directories the server held that the client had here, as known.json keeps them: one gone here is sent as removed
|
|
1635
|
+
* (queueRemovedDirs), and one a snapshot names is made here only when it is not among them
|
|
1636
|
+
*/
|
|
1637
|
+
syncedDirs = /* @__PURE__ */ new Set();
|
|
1638
|
+
/** The check for directories gone here that is to run a moment from now (checkDirs) */
|
|
1639
|
+
dirCheck = null;
|
|
1673
1640
|
/** The hub's seq up to which the client has taken in every change: the last snapshot and the broadcasts after it */
|
|
1674
1641
|
hubSeq = -1;
|
|
1675
1642
|
/** The seqs of the broadcasts on the current connection above hubSeq with a change between that never arrived */
|
|
@@ -1723,6 +1690,13 @@ var SyncClient = class extends EventEmitter {
|
|
|
1723
1690
|
flushRun = null;
|
|
1724
1691
|
resyncing = null;
|
|
1725
1692
|
resyncAgain = false;
|
|
1693
|
+
/**
|
|
1694
|
+
* The snapshot the hub held off while the server migrates or restores it in batches (HubRewritingError): taken again
|
|
1695
|
+
* when its reload broadcast comes, or after the Retry-After it came with, whichever is first. The connection stays
|
|
1696
|
+
* open meanwhile, so the client neither reconnects nor asks for the snapshot more often than that.
|
|
1697
|
+
*/
|
|
1698
|
+
snapshotHeld = false;
|
|
1699
|
+
snapshotTimer;
|
|
1726
1700
|
/** Whether a snapshot has been taken, after which a path the server never mentioned does not exist there */
|
|
1727
1701
|
synced = false;
|
|
1728
1702
|
reconnectTimer;
|
|
@@ -1748,15 +1722,25 @@ var SyncClient = class extends EventEmitter {
|
|
|
1748
1722
|
backoff = 500;
|
|
1749
1723
|
lastPong = 0;
|
|
1750
1724
|
knownTimer;
|
|
1725
|
+
/**
|
|
1726
|
+
* Until when the outbox waits, in ms since the epoch, after the hub refused a write for a while: for this CLI's version
|
|
1727
|
+
* (ClientOutdatedError), or while it is migrated or restored in batches (HubRewritingError). The Retry-After it came
|
|
1728
|
+
* with, so an environment left on an older CLI, or one writing during a rewrite, asks once a while, not once a second.
|
|
1729
|
+
* The write stays in the outbox meanwhile, and idle does not wait for it. With the message last told of an outdated
|
|
1730
|
+
* CLI (a `client-outdated` problem), so the same one is told once, until a write goes through again.
|
|
1731
|
+
*/
|
|
1732
|
+
heldUntil = 0;
|
|
1733
|
+
outdatedMessage = null;
|
|
1751
1734
|
constructor(opts) {
|
|
1752
1735
|
super();
|
|
1753
1736
|
this.opts = opts;
|
|
1754
|
-
this.
|
|
1755
|
-
this.paths = syncPaths(opts.
|
|
1737
|
+
this.dirs = opts.dirs;
|
|
1738
|
+
this.paths = syncPaths(opts.dirs);
|
|
1756
1739
|
this.api = new ServerApi(opts.settings);
|
|
1757
1740
|
this.firstContact = new Promise((resolve) => {
|
|
1758
1741
|
this.once("synced", resolve);
|
|
1759
1742
|
this.once("offline", resolve);
|
|
1743
|
+
this.once("held", resolve);
|
|
1760
1744
|
});
|
|
1761
1745
|
}
|
|
1762
1746
|
trace(line) {
|
|
@@ -1769,19 +1753,13 @@ var SyncClient = class extends EventEmitter {
|
|
|
1769
1753
|
get online() {
|
|
1770
1754
|
return this.connected && !this.stopped;
|
|
1771
1755
|
}
|
|
1772
|
-
/**
|
|
1773
|
-
* Whether the file at `path` is synced: by the sync client's own rules (src/paths.ts) and the hub's .gitignore files as
|
|
1774
|
-
* last read (src/scope.ts). Anything else is left as it is here, whatever the server holds.
|
|
1775
|
-
*/
|
|
1756
|
+
/** Whether the file at `path` is synced (src/paths.ts). Anything else is left as it is here, whatever the server holds. */
|
|
1776
1757
|
syncs(path) {
|
|
1777
|
-
return
|
|
1758
|
+
return isSynced(path);
|
|
1778
1759
|
}
|
|
1779
|
-
/**
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
*/
|
|
1783
|
-
get scopeStale() {
|
|
1784
|
-
return this.scope.generation !== this.scopeTaken;
|
|
1760
|
+
/** Whether the directory at `path` is synced: one that may hold synced files, apart from local/ itself, which every machine has of its own (src/paths.ts). */
|
|
1761
|
+
syncsDir(path) {
|
|
1762
|
+
return syncedDir(path);
|
|
1785
1763
|
}
|
|
1786
1764
|
/** Writes waiting in the outbox for the server: not the ones it refused, which wait for nothing (src/state.ts). */
|
|
1787
1765
|
get queued() {
|
|
@@ -1834,7 +1812,6 @@ var SyncClient = class extends EventEmitter {
|
|
|
1834
1812
|
this.info("", `atomic exchange unavailable, writing with a rename: ${swapper.reason}`);
|
|
1835
1813
|
}
|
|
1836
1814
|
}
|
|
1837
|
-
this.scope.load(this.paths.data);
|
|
1838
1815
|
await this.restoreKnown();
|
|
1839
1816
|
this.restoreRemote();
|
|
1840
1817
|
await this.whenFree(() => this.syncQueue());
|
|
@@ -1859,7 +1836,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
1859
1836
|
async restoreKnown() {
|
|
1860
1837
|
this.copied = !existsSync(this.paths.known);
|
|
1861
1838
|
this.blobsCompared = !this.copied && !existsSync(this.paths.copyBlobs);
|
|
1862
|
-
for (const
|
|
1839
|
+
for (const dir of readKnownDirs(this.dirs)) this.syncedDirs.add(dir);
|
|
1840
|
+
for (const [path, hash] of Object.entries(readKnown(this.dirs))) {
|
|
1863
1841
|
if (hash.startsWith("blob:")) {
|
|
1864
1842
|
const disk = {
|
|
1865
1843
|
hash: hash.slice(5),
|
|
@@ -1870,7 +1848,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
1870
1848
|
continue;
|
|
1871
1849
|
}
|
|
1872
1850
|
const local = await this.readLocal(path);
|
|
1873
|
-
const text = local.kind === "text" && contentHash(local.text) === hash ? local.text : readKnownText(this.
|
|
1851
|
+
const text = local.kind === "text" && contentHash(local.text) === hash ? local.text : readKnownText(this.dirs, hash);
|
|
1874
1852
|
const disk = text === void 0 ? { hash } : {
|
|
1875
1853
|
hash,
|
|
1876
1854
|
text
|
|
@@ -1920,6 +1898,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
1920
1898
|
this.closing = true;
|
|
1921
1899
|
this.stopped = true;
|
|
1922
1900
|
clearTimeout(this.reconnectTimer);
|
|
1901
|
+
clearTimeout(this.snapshotTimer);
|
|
1923
1902
|
clearInterval(this.pingTimer);
|
|
1924
1903
|
clearInterval(this.warmTimer);
|
|
1925
1904
|
clearInterval(this.rescanTimer);
|
|
@@ -1960,6 +1939,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
1960
1939
|
await new Promise((resolve) => {
|
|
1961
1940
|
this.once("synced", resolve);
|
|
1962
1941
|
this.once("offline", resolve);
|
|
1942
|
+
this.once("held", resolve);
|
|
1963
1943
|
this.connect();
|
|
1964
1944
|
});
|
|
1965
1945
|
}
|
|
@@ -1973,7 +1953,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
1973
1953
|
this.outboxChanged();
|
|
1974
1954
|
for (;;) {
|
|
1975
1955
|
const work = besidesAwaiting ? [...this.work].filter((promise) => !this.awaitingEdit.has(this.workPaths.get(promise) ?? "")) : [...this.work];
|
|
1976
|
-
|
|
1956
|
+
const sendable = this.online && this.heldUntil <= Date.now() && this.nextEntry(this.queue).entry !== void 0;
|
|
1957
|
+
if (!(work.length > 0 || [...this.inflight.values()].some((n) => n > 0) || this.flushing || this.resyncing !== null || sendable)) return true;
|
|
1977
1958
|
if (Date.now() > deadline) return false;
|
|
1978
1959
|
await (work.length > 0 ? Promise.race([Promise.allSettled(work), sleep$1(50)]) : sleep$1(50));
|
|
1979
1960
|
}
|
|
@@ -2113,7 +2094,9 @@ var SyncClient = class extends EventEmitter {
|
|
|
2113
2094
|
this.track(this.resync());
|
|
2114
2095
|
return;
|
|
2115
2096
|
}
|
|
2116
|
-
for (const event of message.files) this.
|
|
2097
|
+
for (const event of message.files) if (event.kind === "dir") this.receiveDir(event.path, seq, true);
|
|
2098
|
+
else if (event.kind === "dirRemoved") this.receiveDirRemoved(event.path, seq);
|
|
2099
|
+
else this.receive(event, seq);
|
|
2117
2100
|
if (this.ahead.size > 0 && this.synced && this.resyncing === null) {
|
|
2118
2101
|
this.log(`missed the hub's changes ${this.hubSeq + 1} to ${Math.min(...this.ahead) - 1}; reloading the snapshot`);
|
|
2119
2102
|
this.track(this.resync());
|
|
@@ -2135,10 +2118,6 @@ var SyncClient = class extends EventEmitter {
|
|
|
2135
2118
|
* as a listed text does in a snapshot.
|
|
2136
2119
|
*/
|
|
2137
2120
|
receive(event, seq) {
|
|
2138
|
-
if (event.kind === "dir") {
|
|
2139
|
-
if ((event.path.split("/")[0] !== "local" || event.path === "local/sessions") && !this.scope.ignored(event.path, true)) this.track(mkdir(join(this.paths.data, event.path), { recursive: true }));
|
|
2140
|
-
return;
|
|
2141
|
-
}
|
|
2142
2121
|
if (!this.syncs(event.path)) return;
|
|
2143
2122
|
const file = event.kind === "file" ? event.text : event.kind === "blob" ? {
|
|
2144
2123
|
kind: "blob",
|
|
@@ -2180,6 +2159,58 @@ var SyncClient = class extends EventEmitter {
|
|
|
2180
2159
|
const mainline = MAINLINE_FILE.exec(event.path)?.[1];
|
|
2181
2160
|
if (mainline !== void 0 && this.remote.size > 0) this.fetchOpened(mainline);
|
|
2182
2161
|
}
|
|
2162
|
+
/**
|
|
2163
|
+
* A directory the server holds as of the hub's `seq`, from a broadcast (`live`) or a snapshot. One the client had here
|
|
2164
|
+
* and that is gone now, named by a snapshot, was removed here: it is not made again, and its removal is queued
|
|
2165
|
+
* (queueRemovedDirs). Any other is made here and known from then on; a broadcast's always is, an operation having just
|
|
2166
|
+
* made it. A directory the client does not sync, and a version no newer than the one the client holds, as a snapshot's
|
|
2167
|
+
* after the broadcast of its removal, are left alone.
|
|
2168
|
+
*/
|
|
2169
|
+
receiveDir(path, seq, live) {
|
|
2170
|
+
if (!this.syncsDir(path) || seq <= (this.dirSeq.get(path) ?? -1)) return;
|
|
2171
|
+
this.dirSeq.set(path, seq);
|
|
2172
|
+
const dir = join(this.paths.data, path);
|
|
2173
|
+
if (!live && this.syncedDirs.has(path) && !existsSync(dir)) {
|
|
2174
|
+
this.trace(`dir ${path} seq ${seq}: removed here`);
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
this.knowDir(path);
|
|
2178
|
+
this.track(mkdir(dir, { recursive: true }));
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* The server removed the directory at `path` as of the hub's `seq`, as a broadcast tells, or a snapshot that no longer
|
|
2182
|
+
* names it: removed here too when nothing is in it, under the write lock, so no mainflow write puts a file in it
|
|
2183
|
+
* meanwhile. One that holds anything, as a file written here not sent yet, stays; the file's write makes it on the
|
|
2184
|
+
* server again.
|
|
2185
|
+
*/
|
|
2186
|
+
receiveDirRemoved(path, seq) {
|
|
2187
|
+
if (!this.syncsDir(path) || seq <= (this.dirSeq.get(path) ?? -1)) return;
|
|
2188
|
+
this.dirSeq.set(path, seq);
|
|
2189
|
+
this.forgetDir(path);
|
|
2190
|
+
this.trace(`dir ${path} removed seq ${seq}`);
|
|
2191
|
+
this.track(this.whenFree(() => this.locked(() => this.removeEmptyDir(path))).catch((error) => this.log(`${path}/: ${error.message}`)));
|
|
2192
|
+
}
|
|
2193
|
+
/** Remove the directory at `path` when nothing is in it; one gone already, or holding anything, stays as it is. True when it went. */
|
|
2194
|
+
removeEmptyDir(path) {
|
|
2195
|
+
try {
|
|
2196
|
+
rmdirSync(join(this.paths.data, path));
|
|
2197
|
+
return true;
|
|
2198
|
+
} catch (error) {
|
|
2199
|
+
const code = error.code;
|
|
2200
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST" && code !== "ENOTDIR") throw error;
|
|
2201
|
+
return false;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
/** The server holds the directory at `path`, and the client has it here: known from now on. */
|
|
2205
|
+
knowDir(path) {
|
|
2206
|
+
if (this.syncedDirs.has(path)) return;
|
|
2207
|
+
this.syncedDirs.add(path);
|
|
2208
|
+
this.saveKnown();
|
|
2209
|
+
}
|
|
2210
|
+
/** The server no longer holds the directory at `path`. */
|
|
2211
|
+
forgetDir(path) {
|
|
2212
|
+
if (this.syncedDirs.delete(path)) this.saveKnown();
|
|
2213
|
+
}
|
|
2183
2214
|
/** Whether the client holds `path` as of the hub's `seq` or later. */
|
|
2184
2215
|
reached(path, seq) {
|
|
2185
2216
|
return this.hubSeq >= seq || (this.fileSeq.get(path) ?? -1) >= seq;
|
|
@@ -2222,8 +2253,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2222
2253
|
* reconciled: the server broadcasts a change after storing it, so a snapshot asked for later holds it. One that
|
|
2223
2254
|
* still does not, and brought nothing newer, is asked for again after a pause; after STALLED_SNAPSHOTS of them the
|
|
2224
2255
|
* server is taken to have lost those changes, as when its storage was replaced: the versions the client holds from
|
|
2225
|
-
* after the gap are forgotten and the snapshot is taken once more, as it is.
|
|
2226
|
-
* changed after it was asked for (scopeStale): a file they let in since was not taken in. The connection has its
|
|
2256
|
+
* after the gap are forgotten and the snapshot is taken once more, as it is. The connection has its
|
|
2227
2257
|
* snapshot once one ends while it is open (I1); one that fails leaves it without, and the client reconnects to take a
|
|
2228
2258
|
* fresh one, rather than send on the snapshot of an earlier connection.
|
|
2229
2259
|
*/
|
|
@@ -2247,6 +2277,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2247
2277
|
this.log(`the sync server's snapshot stays without the hub's changes ${missing}; taking it as complete`);
|
|
2248
2278
|
this.ahead.clear();
|
|
2249
2279
|
for (const [path, seq] of this.fileSeq) if (seq > this.hubSeq) this.fileSeq.delete(path);
|
|
2280
|
+
for (const [path, seq] of this.dirSeq) if (seq > this.hubSeq) this.dirSeq.delete(path);
|
|
2250
2281
|
stalled = 0;
|
|
2251
2282
|
this.resyncAgain = true;
|
|
2252
2283
|
continue;
|
|
@@ -2254,11 +2285,17 @@ var SyncClient = class extends EventEmitter {
|
|
|
2254
2285
|
this.log(`missed the hub's changes ${missing} while taking the snapshot; reloading it`);
|
|
2255
2286
|
if (stalled > 0) await sleep$1(250 * 2 ** (stalled - 1));
|
|
2256
2287
|
this.resyncAgain = true;
|
|
2257
|
-
} while (
|
|
2288
|
+
} while (this.resyncAgain && !this.closing);
|
|
2258
2289
|
this.current = this.connected && !this.closing;
|
|
2290
|
+
if (this.snapshotHeld) {
|
|
2291
|
+
this.snapshotHeld = false;
|
|
2292
|
+
clearTimeout(this.snapshotTimer);
|
|
2293
|
+
if (this.current) this.emit("synced");
|
|
2294
|
+
}
|
|
2259
2295
|
} catch (error) {
|
|
2260
2296
|
this.current = false;
|
|
2261
|
-
this.
|
|
2297
|
+
if (error instanceof HubRewritingError && this.connected && !this.closing) this.holdSnapshot(error);
|
|
2298
|
+
else this.ws?.close();
|
|
2262
2299
|
throw error;
|
|
2263
2300
|
} finally {
|
|
2264
2301
|
this.resyncing = null;
|
|
@@ -2268,6 +2305,19 @@ var SyncClient = class extends EventEmitter {
|
|
|
2268
2305
|
return this.resyncing;
|
|
2269
2306
|
}
|
|
2270
2307
|
/**
|
|
2308
|
+
* The server held the snapshot off while it migrates or restores the hub in batches: the connection stays open, and
|
|
2309
|
+
* the snapshot is taken again on the reload broadcast that ends the rewrite, or after the Retry-After of the refusal,
|
|
2310
|
+
* whichever is first. Nothing is sent meanwhile, as before any snapshot. A client starting or reconnecting meanwhile
|
|
2311
|
+
* is done waiting for its first contact (`held`).
|
|
2312
|
+
*/
|
|
2313
|
+
holdSnapshot(error) {
|
|
2314
|
+
if (!this.snapshotHeld) this.log(`${error.message}; the snapshot is taken again once it is done`);
|
|
2315
|
+
this.snapshotHeld = true;
|
|
2316
|
+
clearTimeout(this.snapshotTimer);
|
|
2317
|
+
this.snapshotTimer = setTimeout(() => void this.track(this.resync().catch((failed) => this.log(`could not reload the snapshot: ${failed.message}`))), error.retryAfter * 1e3);
|
|
2318
|
+
this.emit("held");
|
|
2319
|
+
}
|
|
2320
|
+
/**
|
|
2271
2321
|
* Take the hub as of the snapshot's seq, asking only for what differs from what the client holds (holdings). The
|
|
2272
2322
|
* snapshot comes in batches, each as of its own seq; the WebSocket was open before the first, so every change after
|
|
2273
2323
|
* it comes as a broadcast, and the first batch's seq is the one the whole snapshot is as of. A file the server sent,
|
|
@@ -2275,8 +2325,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
2275
2325
|
* batch's seq; a file neither named nor sent is not on the server. A broadcast arriving meanwhile may be older or
|
|
2276
2326
|
* newer than either; each file keeps whichever version has the higher seq. A path the server never held a file at
|
|
2277
2327
|
* (`unknown`) is one it has no file at: a file here, even one never synced or one known.json names, is sent as new and
|
|
2278
|
-
* never removed (reconcile, unheld). What the
|
|
2279
|
-
*
|
|
2328
|
+
* never removed (reconcile, unheld). What the client does not sync (syncs), as a file in a scratch/ directory, is not
|
|
2329
|
+
* taken in, whatever the server sends.
|
|
2280
2330
|
*
|
|
2281
2331
|
* A server without the hub first gets the local one. When it refuses the upload and still holds no file, the snapshot
|
|
2282
2332
|
* fails without taking anything in: an empty hub would tell a copy without known.json to remove every file it holds.
|
|
@@ -2309,8 +2359,6 @@ var SyncClient = class extends EventEmitter {
|
|
|
2309
2359
|
/** Whether the hub was uploaded, and why the server refused the upload; asked again, the snapshot may hold the hub */
|
|
2310
2360
|
let uploaded = false;
|
|
2311
2361
|
let refusal = null;
|
|
2312
|
-
/** The rules' generation the snapshot is asked for under: what it holds is taken in as they leave it in */
|
|
2313
|
-
let asked;
|
|
2314
2362
|
/** Whether the snapshot is asked for with the binary files */
|
|
2315
2363
|
let withBlobs = false;
|
|
2316
2364
|
if (!this.probed) await this.probeBlobs();
|
|
@@ -2321,15 +2369,20 @@ var SyncClient = class extends EventEmitter {
|
|
|
2321
2369
|
have = new Map([...held].map(([path, text]) => [path, this.hashOf(path, text)]));
|
|
2322
2370
|
for (const [path, blob] of heldBlobs) have.set(path, blob.hash);
|
|
2323
2371
|
for (const [path, hash] of this.remote) have.set(path, hash);
|
|
2324
|
-
asked = this.scope.generation;
|
|
2325
2372
|
withBlobs = this.blobSupport === true;
|
|
2326
2373
|
if (!withBlobs && !this.blobsCompared && !existsSync(this.paths.copyBlobs)) {
|
|
2327
2374
|
mkdirSync(this.paths.own, { recursive: true });
|
|
2328
2375
|
writeFileSync(this.paths.copyBlobs, "");
|
|
2329
2376
|
}
|
|
2377
|
+
const dirs = this.knownDirs();
|
|
2378
|
+
const digest = withBlobs && have.size > 0 ? {
|
|
2379
|
+
value: hubDigest(have, dirs),
|
|
2380
|
+
dirs
|
|
2381
|
+
} : void 0;
|
|
2330
2382
|
parts = await this.api.snapshot(have, {
|
|
2331
2383
|
listOnly: this.listOnly(this.copied && !this.synced),
|
|
2332
|
-
blobs: this.blobSupport === true
|
|
2384
|
+
blobs: this.blobSupport === true,
|
|
2385
|
+
...digest ? { digest } : {}
|
|
2333
2386
|
});
|
|
2334
2387
|
if (uploaded || holdsFiles(parts, have)) break;
|
|
2335
2388
|
if (this.opts.settings.onDemand === true) {
|
|
@@ -2338,7 +2391,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2338
2391
|
this.problem({
|
|
2339
2392
|
kind: "upload-needed",
|
|
2340
2393
|
path: "",
|
|
2341
|
-
message: `the sync server has no copy of hub ${this.opts.settings.hub}; this environment downloads on demand and may lack files, so it does not upload its copy. Run mainflow sync, with on-demand downloads off, in an environment that keeps every file of the hub
|
|
2394
|
+
message: `the sync server has no copy of hub ${this.opts.settings.hub}; this environment downloads on demand and may lack files, so it does not upload its copy. Run mainflow sync, with on-demand downloads off, in an environment that keeps every file of the hub (run it there with MAINFLOW_SYNC_ON_DEMAND=0), to upload it; this one waits and syncs once the hub is there`
|
|
2342
2395
|
});
|
|
2343
2396
|
}
|
|
2344
2397
|
throw new Error("the sync server has no copy of the hub, and this environment downloads on demand, so it waits for one that keeps every file to upload it");
|
|
@@ -2362,6 +2415,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2362
2415
|
if (seq < before) {
|
|
2363
2416
|
this.log(`the sync server's seq went back from ${before} to ${seq}; taking its snapshot as it is`);
|
|
2364
2417
|
this.fileSeq.clear();
|
|
2418
|
+
this.dirSeq.clear();
|
|
2365
2419
|
this.hubSeq = -1;
|
|
2366
2420
|
this.ahead.clear();
|
|
2367
2421
|
}
|
|
@@ -2372,8 +2426,16 @@ var SyncClient = class extends EventEmitter {
|
|
|
2372
2426
|
this.snapshotAnswers.clear();
|
|
2373
2427
|
const fromCopy = this.copied && !this.synced;
|
|
2374
2428
|
const blobsFromCopy = withBlobs && !this.blobsCompared;
|
|
2429
|
+
/** The directories the snapshot names: every one the server holds */
|
|
2430
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
2375
2431
|
for (const part of parts) for (const event of part.files) {
|
|
2376
|
-
if (event.kind
|
|
2432
|
+
if (event.kind === "dir") {
|
|
2433
|
+
named.add(event.path);
|
|
2434
|
+
dirs.add(event.path);
|
|
2435
|
+
this.receiveDir(event.path, part.seq, false);
|
|
2436
|
+
continue;
|
|
2437
|
+
}
|
|
2438
|
+
if (!this.syncs(event.path)) continue;
|
|
2377
2439
|
if (event.kind === "listed") {
|
|
2378
2440
|
named.add(event.path);
|
|
2379
2441
|
if (this.mayStay(event.path, held)) this.keepRemote(event.path, event.hash, part.seq);
|
|
@@ -2441,7 +2503,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2441
2503
|
path
|
|
2442
2504
|
}, seq);
|
|
2443
2505
|
}
|
|
2444
|
-
this.
|
|
2506
|
+
for (const path of this.syncedDirs) if (!dirs.has(path)) this.receiveDirRemoved(path, seq);
|
|
2445
2507
|
this.log(`took the snapshot at seq ${seq} in ${parts.length} batch${parts.length === 1 ? "" : "es"}: ${sent} files sent, ${present.size - sent} already held here${this.remote.size > 0 ? `, ${this.remote.size} only on the server` : ""}`);
|
|
2446
2508
|
this.takenUpTo(seq);
|
|
2447
2509
|
this.synced = true;
|
|
@@ -2456,6 +2518,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2456
2518
|
for (const path of await this.walk()) this.schedule(path);
|
|
2457
2519
|
for (const path of this.stalledDownloads) this.schedule(path);
|
|
2458
2520
|
this.stalledDownloads.clear();
|
|
2521
|
+
await this.queueRemovedDirs();
|
|
2459
2522
|
}
|
|
2460
2523
|
/**
|
|
2461
2524
|
* Ask the server whether it keeps binary files (blobSupport), once per connection, before its snapshot. A probe that
|
|
@@ -2618,7 +2681,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2618
2681
|
if (expected?.blob && expected.hash === disk.hash) this.expected.delete(path);
|
|
2619
2682
|
if (isBlob(this.cloud.get(path))) this.cloud.delete(path);
|
|
2620
2683
|
}
|
|
2621
|
-
if (forgotten.size > 0) await this.whenFree(() => underLock(this.
|
|
2684
|
+
if (forgotten.size > 0) await this.whenFree(() => underLock(this.dirs, () => writeKnown(this.dirs, this.knownFiles(), this.knownDirs())));
|
|
2622
2685
|
return forgotten;
|
|
2623
2686
|
}
|
|
2624
2687
|
/** The server refused the upload, so it holds what it held: the binary files forgetBinaries forgot are known again. */
|
|
@@ -2720,7 +2783,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2720
2783
|
const after = this.expected.get(path);
|
|
2721
2784
|
if (after !== seen && after !== void 0 && this.matches(after, local)) return this.schedule(path);
|
|
2722
2785
|
}
|
|
2723
|
-
const target = this.cloud.has(path) ? this.cloud.get(path) : this.synced &&
|
|
2786
|
+
const target = this.cloud.has(path) ? this.cloud.get(path) : this.synced && (this.blobSupport === true || !this.knownBinary(path)) ? null : void 0;
|
|
2724
2787
|
const disk = this.expected.get(path);
|
|
2725
2788
|
const unheld = target === null && this.unheld.has(path);
|
|
2726
2789
|
if (disk === void 0) {
|
|
@@ -2799,7 +2862,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2799
2862
|
}
|
|
2800
2863
|
if (text === null) return false;
|
|
2801
2864
|
try {
|
|
2802
|
-
return decoder.decode(bytes) === text;
|
|
2865
|
+
return decoder$1.decode(bytes) === text;
|
|
2803
2866
|
} catch {
|
|
2804
2867
|
return false;
|
|
2805
2868
|
}
|
|
@@ -2844,6 +2907,9 @@ var SyncClient = class extends EventEmitter {
|
|
|
2844
2907
|
* now on (I4). A version only written here, still in the outbox, is never confirmed.
|
|
2845
2908
|
*/
|
|
2846
2909
|
confirm(path, disk) {
|
|
2910
|
+
if (disk !== null) {
|
|
2911
|
+
for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) if (this.syncsDir(path.slice(0, at))) this.knowDir(path.slice(0, at));
|
|
2912
|
+
}
|
|
2847
2913
|
const held = this.confirmed.get(path);
|
|
2848
2914
|
if (held === void 0 ? false : held === null ? disk === null : disk !== null && held.hash === disk.hash && (held.text !== void 0 || disk.text === void 0)) return;
|
|
2849
2915
|
this.confirmed.set(path, disk);
|
|
@@ -2908,7 +2974,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2908
2974
|
this.tellOnce(`large:${path}:${hash}`, {
|
|
2909
2975
|
kind: "skipped-large",
|
|
2910
2976
|
path,
|
|
2911
|
-
message: `${path} is larger than ${limit}, so it is not synced and not overwritten; other environments do not see it. ${binary ? "Make it smaller" : "Split it"}, or
|
|
2977
|
+
message: `${path} is larger than ${limit}, so it is not synced and not overwritten; other environments do not see it. ${binary ? "Make it smaller" : "Split it"}, or move it into a scratch/ directory, which is never synced`
|
|
2912
2978
|
});
|
|
2913
2979
|
}
|
|
2914
2980
|
/**
|
|
@@ -3065,7 +3131,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
3065
3131
|
const fetched = [];
|
|
3066
3132
|
for (const part of parts) for (const event of part.files) {
|
|
3067
3133
|
if (event.kind === "dir") {
|
|
3068
|
-
this.
|
|
3134
|
+
this.receiveDir(event.path, part.seq, false);
|
|
3069
3135
|
continue;
|
|
3070
3136
|
}
|
|
3071
3137
|
mentioned.add(event.path);
|
|
@@ -3223,7 +3289,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
3223
3289
|
await this.fetchPaths([path]).catch((error) => this.log(`${path}: could not download it: ${error.message}`));
|
|
3224
3290
|
}
|
|
3225
3291
|
restoreRemote() {
|
|
3226
|
-
for (const [path, hash] of Object.entries(readRemote(this.
|
|
3292
|
+
for (const [path, hash] of Object.entries(readRemote(this.dirs))) if (this.syncs(path) && artifactOf(path) !== null && this.expected.get(path) === void 0) this.remote.set(path, hash);
|
|
3227
3293
|
}
|
|
3228
3294
|
/**
|
|
3229
3295
|
* What remote.json lists: the files only on the server, and the artifacts whose download failed and waits for a retry.
|
|
@@ -3258,7 +3324,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
3258
3324
|
const listed = this.listedRemote();
|
|
3259
3325
|
if (listed.size === 0 && !existsSync(this.paths.remote)) return;
|
|
3260
3326
|
try {
|
|
3261
|
-
writeRemote(this.
|
|
3327
|
+
writeRemote(this.dirs, Object.fromEntries([...listed].sort(([a], [b]) => comparePaths(a, b))));
|
|
3262
3328
|
} catch (error) {
|
|
3263
3329
|
this.log(`could not save local/sync/remote.json: ${error.message}`);
|
|
3264
3330
|
}
|
|
@@ -3843,6 +3909,88 @@ var SyncClient = class extends EventEmitter {
|
|
|
3843
3909
|
this.trace(`removal of ${path} changed before it was queued; reconciling it again`);
|
|
3844
3910
|
this.schedule(path);
|
|
3845
3911
|
}
|
|
3912
|
+
/**
|
|
3913
|
+
* Queue the removal of each directory the client had here that is gone (syncedDirs), with nothing the client knows of
|
|
3914
|
+
* in it: no file the client left there and has not sent the removal of, no write in the outbox of a file there, which
|
|
3915
|
+
* goes first, and no file the server holds there that is not written here yet, nor one only on the server. The
|
|
3916
|
+
* deepest go first, so the server finds the ones in each removed already; one holding anything holds the ones above it
|
|
3917
|
+
* too. Each is checked again in the hold of the write lock that queues it, so a directory made again meanwhile stays.
|
|
3918
|
+
* Only once a snapshot was taken, since what the server holds is unknown before.
|
|
3919
|
+
*/
|
|
3920
|
+
async queueRemovedDirs() {
|
|
3921
|
+
if (!this.synced || this.closing) return;
|
|
3922
|
+
const exists = (dir) => existsSync(join(this.paths.data, dir));
|
|
3923
|
+
const gone = [...this.syncedDirs].filter((dir) => this.syncsDir(dir) && !exists(dir));
|
|
3924
|
+
if (gone.length === 0) return;
|
|
3925
|
+
gone.sort((a, b) => b.split("/").length - a.split("/").length || comparePaths(a, b));
|
|
3926
|
+
const queued = [];
|
|
3927
|
+
await this.whenFree(() => this.editQueue((entries) => {
|
|
3928
|
+
const files = [
|
|
3929
|
+
...[...this.expected].flatMap(([path, disk]) => disk === null ? [] : [path]),
|
|
3930
|
+
...[...this.cloud].flatMap(([path, file]) => file === null ? [] : [path]),
|
|
3931
|
+
...this.remote.keys(),
|
|
3932
|
+
...this.awaiting,
|
|
3933
|
+
...entries.flatMap(entryPaths)
|
|
3934
|
+
];
|
|
3935
|
+
const held = /* @__PURE__ */ new Set();
|
|
3936
|
+
const hold = (dir) => {
|
|
3937
|
+
for (let at = dir.length; at > 0; at = dir.lastIndexOf("/", at - 1)) held.add(dir.slice(0, at));
|
|
3938
|
+
};
|
|
3939
|
+
for (const path of files) hold(path.slice(0, Math.max(0, path.lastIndexOf("/"))));
|
|
3940
|
+
for (const dir of gone) {
|
|
3941
|
+
if (held.has(dir) || exists(dir)) {
|
|
3942
|
+
hold(dir);
|
|
3943
|
+
continue;
|
|
3944
|
+
}
|
|
3945
|
+
if (entries.some((entry) => entry.kind === "rmdir" && entry.path === dir)) continue;
|
|
3946
|
+
entries.push({
|
|
3947
|
+
kind: "rmdir",
|
|
3948
|
+
path: dir,
|
|
3949
|
+
id: entryId()
|
|
3950
|
+
});
|
|
3951
|
+
queued.push(dir);
|
|
3952
|
+
}
|
|
3953
|
+
return queued.length > 0;
|
|
3954
|
+
}));
|
|
3955
|
+
if (queued.length === 0) return;
|
|
3956
|
+
this.log(`queued rmdir ${queued.join(", ")} (${this.queue.length} waiting)`);
|
|
3957
|
+
if (this.online) this.track(this.flushQueue());
|
|
3958
|
+
}
|
|
3959
|
+
/**
|
|
3960
|
+
* Queue the removal of the directories gone here a moment from now, once for every call in that moment; counted as
|
|
3961
|
+
* work under way meanwhile, so idle() waits for it.
|
|
3962
|
+
*/
|
|
3963
|
+
checkDirs() {
|
|
3964
|
+
if (this.closing || this.dirCheck) return;
|
|
3965
|
+
this.dirCheck = this.track(sleep$1(SETTLE_MS).then(() => {
|
|
3966
|
+
this.dirCheck = null;
|
|
3967
|
+
return this.queueRemovedDirs();
|
|
3968
|
+
}).catch((error) => this.log(`directories: ${error.message}`)));
|
|
3969
|
+
}
|
|
3970
|
+
/**
|
|
3971
|
+
* What the server answered to the removal of a directory: removed, or not there, it is known no more. Kept, as when
|
|
3972
|
+
* another environment has just put a file in it, it is made here again, as the server holds it, so it is not sent
|
|
3973
|
+
* again; the file comes as the server's broadcast. Refused, as by a server from before dir.remove (`unknown-operation`),
|
|
3974
|
+
* it is known no more either: the next snapshot makes it here again, as before, rather than it being sent again and again.
|
|
3975
|
+
*/
|
|
3976
|
+
async settleRmdir(entry, answer) {
|
|
3977
|
+
const { path } = entry;
|
|
3978
|
+
const response = answer;
|
|
3979
|
+
this.trace(`response ${path}/ ${response.ok ? response.value : `${response.reason}: ${response.message}`}`);
|
|
3980
|
+
if (!response.ok) {
|
|
3981
|
+
this.forgetDir(path);
|
|
3982
|
+
this.dirSeq.delete(path);
|
|
3983
|
+
this.info(path, `the directory was removed here and the sync server did not remove it (${response.message}); it comes back with the next snapshot`);
|
|
3984
|
+
return;
|
|
3985
|
+
}
|
|
3986
|
+
if (response.value === "kept") {
|
|
3987
|
+
for (const notice of response.notices) this.info(notice.path, `${notice.message} (removed here; the directory is back)`);
|
|
3988
|
+
await this.whenFree(() => this.locked(() => mkdirSync(join(this.paths.data, path), { recursive: true })));
|
|
3989
|
+
return;
|
|
3990
|
+
}
|
|
3991
|
+
this.forgetDir(path);
|
|
3992
|
+
if (response.files.length > 0) await this.delivery(path, response.seq);
|
|
3993
|
+
}
|
|
3846
3994
|
async settleRemove(entry, response) {
|
|
3847
3995
|
const { path } = entry;
|
|
3848
3996
|
if (!response.ok) return this.refuse(entry, [{
|
|
@@ -3920,7 +4068,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
3920
4068
|
hash: entry.hash,
|
|
3921
4069
|
blob: true
|
|
3922
4070
|
});
|
|
3923
|
-
else this.expected.set(entry.path, null);
|
|
4071
|
+
else if (entry.kind === "remove") this.expected.set(entry.path, null);
|
|
3924
4072
|
}
|
|
3925
4073
|
/**
|
|
3926
4074
|
* Hold the write lock for `fn`; work stop() abandoned gets an error ('abandoned after stop') instead, so it leaves
|
|
@@ -3929,7 +4077,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
3929
4077
|
*/
|
|
3930
4078
|
locked(fn) {
|
|
3931
4079
|
if (this.abandoned) throw new Error("abandoned after stop");
|
|
3932
|
-
return underLock(this.
|
|
4080
|
+
return underLock(this.dirs, fn);
|
|
3933
4081
|
}
|
|
3934
4082
|
/**
|
|
3935
4083
|
* Change the outbox: read queue.json, take in the entries other writers appended since it was last read, let `edit`
|
|
@@ -3944,7 +4092,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
3944
4092
|
}
|
|
3945
4093
|
/** editQueue for a caller that holds the write lock already. */
|
|
3946
4094
|
editHeld(edit) {
|
|
3947
|
-
const entries = readQueue(this.
|
|
4095
|
+
const entries = readQueue(this.dirs);
|
|
3948
4096
|
const known = new Set(this.queue.map((entry) => entry.id));
|
|
3949
4097
|
let changed = false;
|
|
3950
4098
|
const fresh = [];
|
|
@@ -3968,7 +4116,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
3968
4116
|
this.resolveBases(entries);
|
|
3969
4117
|
}
|
|
3970
4118
|
if (changed) {
|
|
3971
|
-
writeQueue(this.
|
|
4119
|
+
writeQueue(this.dirs, entries);
|
|
3972
4120
|
this.pruneStaged(entries);
|
|
3973
4121
|
}
|
|
3974
4122
|
this.queue = entries;
|
|
@@ -4166,6 +4314,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
4166
4314
|
async sendOutbox() {
|
|
4167
4315
|
this.flushing = true;
|
|
4168
4316
|
try {
|
|
4317
|
+
const held = this.heldUntil - Date.now();
|
|
4318
|
+
if (held > 0) return this.retryQueue(held);
|
|
4169
4319
|
while (this.caughtUp) {
|
|
4170
4320
|
let entry;
|
|
4171
4321
|
let wake;
|
|
@@ -4379,7 +4529,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
4379
4529
|
if (at < 0) return false;
|
|
4380
4530
|
entries.splice(at, 1);
|
|
4381
4531
|
for (const later of entries.slice(at)) {
|
|
4382
|
-
if (later.kind === "op" || later.path !== entry.path || later.sent !== void 0 || later.base !== entry.hash) continue;
|
|
4532
|
+
if (later.kind === "op" || later.kind === "rmdir" || later.path !== entry.path || later.sent !== void 0 || later.base !== entry.hash) continue;
|
|
4383
4533
|
if (later.kind === "remove" && entry.base === null) continue;
|
|
4384
4534
|
later.base = entry.base;
|
|
4385
4535
|
}
|
|
@@ -4422,6 +4572,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
4422
4572
|
this.renumbering.delete(entry.id);
|
|
4423
4573
|
if (renumbered) this.log(`renumbered ${renumbered.map((r) => `${r.from} → ${r.to}`).join(", ")}`);
|
|
4424
4574
|
for (const path of entryPaths(entry)) this.schedule(path);
|
|
4575
|
+
if (entry.kind !== "rmdir") this.checkDirs();
|
|
4425
4576
|
this.emit("settled", entry.id);
|
|
4426
4577
|
}
|
|
4427
4578
|
/**
|
|
@@ -4489,12 +4640,31 @@ var SyncClient = class extends EventEmitter {
|
|
|
4489
4640
|
} catch (error) {
|
|
4490
4641
|
if (!(error instanceof Unreachable)) throw error;
|
|
4491
4642
|
this.trace(`send ${entry.id} failed: ${error.message}`);
|
|
4643
|
+
if (error instanceof HubRewritingError) {
|
|
4644
|
+
this.heldUntil = Date.now() + error.retryAfter * 1e3;
|
|
4645
|
+
this.log(`${error.message}; the outbox is sent again in ${error.retryAfter} s`);
|
|
4646
|
+
return null;
|
|
4647
|
+
}
|
|
4648
|
+
if (error instanceof ClientOutdatedError) {
|
|
4649
|
+
this.heldUntil = Date.now() + error.retryAfter * 1e3;
|
|
4650
|
+
if (error.message !== this.outdatedMessage) {
|
|
4651
|
+
const paths = entryPaths(entry);
|
|
4652
|
+
this.problem({
|
|
4653
|
+
kind: "client-outdated",
|
|
4654
|
+
path: paths[0] ?? "",
|
|
4655
|
+
message: `${error.message}. ${request.op}${paths.length ? ` of ${paths.join(", ")}` : ""} and the writes after it wait in the outbox, sent again every ${error.retryAfter} s until then`
|
|
4656
|
+
});
|
|
4657
|
+
}
|
|
4658
|
+
this.outdatedMessage = error.message;
|
|
4659
|
+
return null;
|
|
4660
|
+
}
|
|
4492
4661
|
if (error.broken && !fresh && !this.closing) return this.request(entry, request, true, true);
|
|
4493
4662
|
return null;
|
|
4494
4663
|
}
|
|
4664
|
+
this.outdatedMessage = null;
|
|
4495
4665
|
if (!response.ok && response.reason === "uncertain") {
|
|
4496
4666
|
const paths = entryPaths(entry);
|
|
4497
|
-
this.problem({
|
|
4667
|
+
if (entry.kind !== "rmdir") this.problem({
|
|
4498
4668
|
kind: "uncertain",
|
|
4499
4669
|
path: paths[0] ?? "",
|
|
4500
4670
|
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`
|
|
@@ -4505,8 +4675,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
4505
4675
|
}
|
|
4506
4676
|
/**
|
|
4507
4677
|
* The request an outbox entry sends, made when it is first sent, from the entry alone: an operation on the current
|
|
4508
|
-
* protocol, a deletion with the time it is first sent, an Edit's replacement to be made again on
|
|
4509
|
-
* version, an edit as patchRequest makes it. Null for an edit that cannot be sent: its base is a write the server
|
|
4678
|
+
* protocol, a deletion with the time it is first sent, a directory's removal, an Edit's replacement to be made again on
|
|
4679
|
+
* the server's latest version, an edit as patchRequest makes it. Null for an edit that cannot be sent: its base is a write the server
|
|
4510
4680
|
* refused or did not take as it was, so its base never became known, or moving it onto the server's merge overlaps.
|
|
4511
4681
|
* Every write ahead of it was answered, so a base still unknown never will be.
|
|
4512
4682
|
*/
|
|
@@ -4529,6 +4699,11 @@ var SyncClient = class extends EventEmitter {
|
|
|
4529
4699
|
size: entry.size,
|
|
4530
4700
|
base: entry.base
|
|
4531
4701
|
};
|
|
4702
|
+
if (entry.kind === "rmdir") return {
|
|
4703
|
+
protocol: 3,
|
|
4704
|
+
op: "dir.remove",
|
|
4705
|
+
path: entry.path
|
|
4706
|
+
};
|
|
4532
4707
|
if (entry.replace) return {
|
|
4533
4708
|
protocol: 3,
|
|
4534
4709
|
op: "text.replace",
|
|
@@ -4675,6 +4850,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
4675
4850
|
last: entry.replaced
|
|
4676
4851
|
}, request.text);
|
|
4677
4852
|
else if (entry.kind === "blob") await this.settleBlob(entry, response);
|
|
4853
|
+
else if (entry.kind === "rmdir") await this.settleRmdir(entry, response);
|
|
4678
4854
|
return true;
|
|
4679
4855
|
} finally {
|
|
4680
4856
|
for (const path of paths) {
|
|
@@ -4785,7 +4961,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
4785
4961
|
*/
|
|
4786
4962
|
renumberDisk(paths, renumbered) {
|
|
4787
4963
|
const storage = {
|
|
4788
|
-
...nodeStorage(this.
|
|
4964
|
+
...nodeStorage(this.dirs),
|
|
4789
4965
|
lock: (fn) => fn()
|
|
4790
4966
|
};
|
|
4791
4967
|
const seen = new Map([...new Set(paths)].filter((path) => this.syncs(path)).map((path) => [path, this.expected.get(path)]));
|
|
@@ -4827,6 +5003,26 @@ var SyncClient = class extends EventEmitter {
|
|
|
4827
5003
|
text
|
|
4828
5004
|
});
|
|
4829
5005
|
}
|
|
5006
|
+
for (const path of [...seen.keys(), ...binary.map(([path]) => path)]) this.pruneMoved(path, renumberPath(path, renumbered));
|
|
5007
|
+
}
|
|
5008
|
+
/**
|
|
5009
|
+
* Remove the directories above `from` that its move to `to` emptied, the deepest first, up to the first one that still
|
|
5010
|
+
* holds anything or is above `to` as well. One the server holds goes on the server too, as any directory gone here
|
|
5011
|
+
* (queueRemovedDirs). It does not take the write lock: its caller holds it.
|
|
5012
|
+
*/
|
|
5013
|
+
pruneMoved(from, to) {
|
|
5014
|
+
if (from === to) return;
|
|
5015
|
+
for (let at = from.lastIndexOf("/"); at > 0; at = from.lastIndexOf("/", at - 1)) {
|
|
5016
|
+
const dir = from.slice(0, at);
|
|
5017
|
+
if (to.startsWith(`${dir}/`)) return;
|
|
5018
|
+
try {
|
|
5019
|
+
if (!this.removeEmptyDir(dir)) return;
|
|
5020
|
+
} catch (error) {
|
|
5021
|
+
this.log(`${dir}/: could not remove it after renumbering: ${error.message}`);
|
|
5022
|
+
return;
|
|
5023
|
+
}
|
|
5024
|
+
this.trace(`dir ${dir} emptied by renumbering, removed here`);
|
|
5025
|
+
}
|
|
4830
5026
|
}
|
|
4831
5027
|
commit(storage, changes) {
|
|
4832
5028
|
for (const change of changes) if (change.kind === "write") storage.write(change.path, change.text);
|
|
@@ -5098,16 +5294,9 @@ var SyncClient = class extends EventEmitter {
|
|
|
5098
5294
|
}]);
|
|
5099
5295
|
if ("files" in response && response.files.some((f) => f.path === path)) await this.delivery(path, response.seq);
|
|
5100
5296
|
}
|
|
5101
|
-
/**
|
|
5102
|
-
* The synced files here, by path. Each directory's .gitignore is read again on the way, before what the directory
|
|
5103
|
-
* holds, so a change to one takes effect from the next scan on; a directory the rules leave out is not walked into.
|
|
5104
|
-
* Rules changed since the last snapshot was asked for take it again: a file they let in that is on the server, as
|
|
5105
|
-
* another environment sent it while the rules here left it out, comes then, not only on the next connection. A walk
|
|
5106
|
-
* of a snapshot being taken leaves that to it (resync).
|
|
5107
|
-
*/
|
|
5297
|
+
/** The synced files here, by path: a walk into the directories that may hold them alone (src/paths.ts). */
|
|
5108
5298
|
async walk() {
|
|
5109
5299
|
const out = [];
|
|
5110
|
-
const dirs = /* @__PURE__ */ new Set();
|
|
5111
5300
|
const visit = async (dir) => {
|
|
5112
5301
|
let entries;
|
|
5113
5302
|
try {
|
|
@@ -5115,51 +5304,31 @@ var SyncClient = class extends EventEmitter {
|
|
|
5115
5304
|
} catch {
|
|
5116
5305
|
return;
|
|
5117
5306
|
}
|
|
5118
|
-
dirs.add(dir);
|
|
5119
|
-
this.scope.set(dir, entries.some((entry) => entry.name === ".gitignore" && entry.isFile()) ? await this.readRules(dir) : null);
|
|
5120
5307
|
for (const entry of entries) {
|
|
5121
5308
|
const path = dir ? `${dir}/${entry.name}` : entry.name;
|
|
5122
5309
|
if (entry.isDirectory()) {
|
|
5123
|
-
if (
|
|
5310
|
+
if (mayHoldSynced(path)) await visit(path);
|
|
5124
5311
|
} else if (entry.isFile() && this.syncs(path)) out.push(path);
|
|
5125
5312
|
}
|
|
5126
5313
|
};
|
|
5127
5314
|
await visit("");
|
|
5128
|
-
this.scope.keepOnly(dirs);
|
|
5129
|
-
if (this.scopeStale && this.synced && this.connected && this.resyncing === null && !this.closing) {
|
|
5130
|
-
this.log("the hub's .gitignore changed; reloading the snapshot for the files it lets in");
|
|
5131
|
-
this.track(this.resync().catch((error) => this.log(`could not reload the snapshot: ${error.message}`)));
|
|
5132
|
-
}
|
|
5133
5315
|
return out;
|
|
5134
5316
|
}
|
|
5135
|
-
/**
|
|
5136
|
-
* The text of the .gitignore in `dir`, which is there, as walk takes it. A text other than the rules have may be one
|
|
5137
|
-
* being written, as `cat >` empties a file before it fills it, and a rule missing for a moment would let its files in,
|
|
5138
|
-
* to be sent: it is read again after EMPTY_RECHECK_MS and taken only when it reads the same, empty too. Until then, and
|
|
5139
|
-
* while the file cannot be read, the rules stay as they were; the next walk reads it again.
|
|
5140
|
-
*/
|
|
5141
|
-
async readRules(dir) {
|
|
5142
|
-
const held = this.scope.text(dir);
|
|
5143
|
-
const read = () => readFile(join(this.paths.data, dir, GITIGNORE), "utf8").catch(() => null);
|
|
5144
|
-
const text = await read();
|
|
5145
|
-
if (text === null || text === held) return held;
|
|
5146
|
-
await sleep$1(EMPTY_RECHECK_MS);
|
|
5147
|
-
return await read() === text ? text : held;
|
|
5148
|
-
}
|
|
5149
|
-
/** The directories an upload holds: every one outside local/ that the hub's .gitignore files leave in. */
|
|
5317
|
+
/** The directories an upload holds: every synced one (syncsDir) outside local/. */
|
|
5150
5318
|
async walkDirs(dir = "") {
|
|
5151
5319
|
const out = [];
|
|
5152
5320
|
const entries = await readdir(join(this.paths.data, dir), { withFileTypes: true }).catch(() => []);
|
|
5153
5321
|
for (const entry of entries) {
|
|
5154
5322
|
const path = dir ? `${dir}/${entry.name}` : entry.name;
|
|
5155
|
-
if (!entry.isDirectory() || path === "local" ||
|
|
5323
|
+
if (!entry.isDirectory() || path === "local" || !this.syncsDir(path)) continue;
|
|
5156
5324
|
out.push(path, ...await this.walkDirs(path));
|
|
5157
5325
|
}
|
|
5158
5326
|
return out;
|
|
5159
5327
|
}
|
|
5160
5328
|
/**
|
|
5161
5329
|
* Compare every synced file's size, modification time and inode with the last scan and reconcile what changed,
|
|
5162
|
-
* plus every file expected on disk that is gone
|
|
5330
|
+
* plus every file expected on disk that is gone, and queue the removal of the directories gone here
|
|
5331
|
+
* (queueRemovedDirs). A file modified in the last moment may still be being written,
|
|
5163
5332
|
* so it waits for the next scan unless `immediate`. `baseline` only records what is there. An immediate scan waits for
|
|
5164
5333
|
* the reconciles it started unless `settle` is false, for a caller that waits for them itself (flushTool).
|
|
5165
5334
|
*/
|
|
@@ -5180,6 +5349,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
5180
5349
|
this.signatures.delete(path);
|
|
5181
5350
|
this.schedule(path);
|
|
5182
5351
|
}
|
|
5352
|
+
await this.queueRemovedDirs();
|
|
5183
5353
|
if (settle) await Promise.allSettled(this.chains.values());
|
|
5184
5354
|
}
|
|
5185
5355
|
/**
|
|
@@ -5197,13 +5367,13 @@ var SyncClient = class extends EventEmitter {
|
|
|
5197
5367
|
}, SETTLE_MS));
|
|
5198
5368
|
return;
|
|
5199
5369
|
}
|
|
5200
|
-
|
|
5201
|
-
if (!
|
|
5370
|
+
if (this.syncedDirs.has(path)) this.checkDirs();
|
|
5371
|
+
if (!this.syncs(path)) return;
|
|
5202
5372
|
this.trace(`watched ${path}`);
|
|
5203
5373
|
clearTimeout(timers.get(path));
|
|
5204
5374
|
timers.set(path, setTimeout(() => {
|
|
5205
5375
|
timers.delete(path);
|
|
5206
|
-
this.track(this.whenSettled(path).then(() =>
|
|
5376
|
+
this.track(this.whenSettled(path).then(() => this.schedule(path)));
|
|
5207
5377
|
}, SETTLE_MS));
|
|
5208
5378
|
};
|
|
5209
5379
|
try {
|
|
@@ -5296,13 +5466,18 @@ var SyncClient = class extends EventEmitter {
|
|
|
5296
5466
|
for (const [path, disk] of this.confirmed) if (disk) files[path] = disk.blob ? { hash: `${BLOB_PREFIX}${disk.hash}` } : disk;
|
|
5297
5467
|
return files;
|
|
5298
5468
|
}
|
|
5469
|
+
/** The directories known.json names (syncedDirs), in path order. */
|
|
5470
|
+
knownDirs() {
|
|
5471
|
+
return [...this.syncedDirs].sort(comparePaths);
|
|
5472
|
+
}
|
|
5299
5473
|
saveKnownNow(final = false) {
|
|
5300
5474
|
if (this.abandoned && !final) return;
|
|
5301
5475
|
clearTimeout(this.knownTimer);
|
|
5302
5476
|
this.knownTimer = void 0;
|
|
5303
5477
|
const files = this.knownFiles();
|
|
5478
|
+
const dirs = this.knownDirs();
|
|
5304
5479
|
try {
|
|
5305
|
-
underLock(this.
|
|
5480
|
+
underLock(this.dirs, () => writeKnown(this.dirs, files, dirs));
|
|
5306
5481
|
} catch (error) {
|
|
5307
5482
|
this.log(`could not save local/sync/known.json: ${error.message}`);
|
|
5308
5483
|
}
|
|
@@ -5312,8 +5487,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
5312
5487
|
//#region ../sync/dist/ipc.js
|
|
5313
5488
|
const SECRET_HEADER = "x-mainflow-sync";
|
|
5314
5489
|
/** The running sync client of the hub at `root`, or null when none runs. */
|
|
5315
|
-
function readDaemon(
|
|
5316
|
-
const file = syncPaths(
|
|
5490
|
+
function readDaemon(dirs) {
|
|
5491
|
+
const file = syncPaths(dirs).daemon;
|
|
5317
5492
|
if (!existsSync(file)) return null;
|
|
5318
5493
|
try {
|
|
5319
5494
|
const info = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -5366,8 +5541,8 @@ async function callDaemon(daemon, route, body, timeoutMs = 6e4) {
|
|
|
5366
5541
|
/** How long a probe waits for the sync client's answer; a client holding the write lock answers after it. */
|
|
5367
5542
|
const PROBE_MS = 2e3;
|
|
5368
5543
|
/** The sync client daemon.json names, asked whether it runs; null without daemon.json or a live process at its pid. */
|
|
5369
|
-
async function probeDaemon(
|
|
5370
|
-
const daemon = readDaemon(
|
|
5544
|
+
async function probeDaemon(dirs, timeoutMs = PROBE_MS) {
|
|
5545
|
+
const daemon = readDaemon(dirs);
|
|
5371
5546
|
if (!daemon) return null;
|
|
5372
5547
|
try {
|
|
5373
5548
|
return {
|
|
@@ -5393,9 +5568,9 @@ const sameDaemon = (a, b) => a !== null && a.pid === b.pid && a.port === b.port
|
|
|
5393
5568
|
* Remove daemon.json when it still says what `gone` said, under the write lock, so a sync client that claimed the hub
|
|
5394
5569
|
* meanwhile keeps it. Call holding the lock.
|
|
5395
5570
|
*/
|
|
5396
|
-
function forgetDaemon(
|
|
5397
|
-
if (!sameDaemon(readDaemon(
|
|
5398
|
-
rmSync(syncPaths(
|
|
5571
|
+
function forgetDaemon(dirs, gone) {
|
|
5572
|
+
if (!sameDaemon(readDaemon(dirs), gone)) return false;
|
|
5573
|
+
rmSync(syncPaths(dirs).daemon, { force: true });
|
|
5399
5574
|
return true;
|
|
5400
5575
|
}
|
|
5401
5576
|
/** The Edit a request's body names, its file and its one replacement, or null. */
|
|
@@ -5419,7 +5594,7 @@ function callIn(body) {
|
|
|
5419
5594
|
};
|
|
5420
5595
|
}
|
|
5421
5596
|
/** Serve `client` to this machine's mainflow commands until closed. */
|
|
5422
|
-
async function serveIpc(
|
|
5597
|
+
async function serveIpc(dirs, client) {
|
|
5423
5598
|
const secret = randomBytes(16).toString("hex");
|
|
5424
5599
|
const server = createServer(async (req, res) => {
|
|
5425
5600
|
const reply = (status, value) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value));
|
|
@@ -5466,13 +5641,13 @@ async function serveIpc(root, client) {
|
|
|
5466
5641
|
});
|
|
5467
5642
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
5468
5643
|
const port = server.address().port;
|
|
5469
|
-
const file = syncPaths(
|
|
5470
|
-
const probe = await probeDaemon(
|
|
5471
|
-
const other = underLock(
|
|
5472
|
-
const running = readDaemon(
|
|
5644
|
+
const file = syncPaths(dirs).daemon;
|
|
5645
|
+
const probe = await probeDaemon(dirs);
|
|
5646
|
+
const other = underLock(dirs, () => {
|
|
5647
|
+
const running = readDaemon(dirs);
|
|
5473
5648
|
const reused = running !== null && probe?.state === "gone" && sameDaemon(running, probe.daemon);
|
|
5474
5649
|
if (running && running.pid !== process.pid && !reused) return running;
|
|
5475
|
-
mkdirSync(syncPaths(
|
|
5650
|
+
mkdirSync(syncPaths(dirs).own, { recursive: true });
|
|
5476
5651
|
writeFileSync(file, JSON.stringify({
|
|
5477
5652
|
pid: process.pid,
|
|
5478
5653
|
port,
|
|
@@ -5500,7 +5675,7 @@ async function serveIpc(root, client) {
|
|
|
5500
5675
|
//#region ../sync/dist/daemon.js
|
|
5501
5676
|
async function startDaemon(options) {
|
|
5502
5677
|
const client = new SyncClient(options);
|
|
5503
|
-
const ipc = await serveIpc(options.
|
|
5678
|
+
const ipc = await serveIpc(options.dirs, client);
|
|
5504
5679
|
try {
|
|
5505
5680
|
await client.start();
|
|
5506
5681
|
} catch (error) {
|
|
@@ -5544,11 +5719,11 @@ const FETCH_TOOLS = /* @__PURE__ */ new Set([
|
|
|
5544
5719
|
"MultiEdit"
|
|
5545
5720
|
]);
|
|
5546
5721
|
/** The file of the hub at `root` that `payload` names in file_path, relative to .mainflow/; null outside it. */
|
|
5547
|
-
function hubPath(
|
|
5722
|
+
function hubPath(dirs, payload) {
|
|
5548
5723
|
const input = payload.tool_input ?? {};
|
|
5549
5724
|
if (typeof input.file_path !== "string") return null;
|
|
5550
5725
|
const file = isAbsolute(input.file_path) ? input.file_path : resolve(payload.cwd ?? process.cwd(), input.file_path);
|
|
5551
|
-
const path = relative(
|
|
5726
|
+
const path = relative(dirs.data, file).split(sep).join("/");
|
|
5552
5727
|
if (path.startsWith("../") || path === ".." || isAbsolute(path)) return null;
|
|
5553
5728
|
return path;
|
|
5554
5729
|
}
|
|
@@ -5570,11 +5745,11 @@ function editOf(path, payload) {
|
|
|
5570
5745
|
* needs nothing; an Edit of one replacement is replayed on the latest version; another file tool names the file it
|
|
5571
5746
|
* wrote; anything else, such as a Bash command that may have written any file, rescans every file and names none.
|
|
5572
5747
|
*/
|
|
5573
|
-
function planFlush(
|
|
5748
|
+
function planFlush(dirs, payload) {
|
|
5574
5749
|
const tool = payload.tool_name ?? "";
|
|
5575
5750
|
if (!FILE_TOOLS.has(tool)) return { skip: false };
|
|
5576
5751
|
if (typeof (payload.tool_input ?? {}).file_path !== "string") return { skip: false };
|
|
5577
|
-
const path = hubPath(
|
|
5752
|
+
const path = hubPath(dirs, payload);
|
|
5578
5753
|
if (path === null || !isSynced(path)) return { skip: true };
|
|
5579
5754
|
const edit = editOf(path, payload);
|
|
5580
5755
|
return edit ? {
|
|
@@ -5602,11 +5777,11 @@ function hookAnswer(result) {
|
|
|
5602
5777
|
* What to download before the tool in `payload` runs in the hub at `root`: the artifact it reads or writes, when the file
|
|
5603
5778
|
* is not here, so it may be only on the sync server. Null for anything else, which needs nothing.
|
|
5604
5779
|
*/
|
|
5605
|
-
function planFetch(
|
|
5780
|
+
function planFetch(dirs, payload) {
|
|
5606
5781
|
const tool = payload.tool_name ?? "";
|
|
5607
5782
|
if (!FETCH_TOOLS.has(tool)) return null;
|
|
5608
|
-
const path = hubPath(
|
|
5609
|
-
if (path === null || !isSynced(path) || artifactOf(path) === null || existsSync(join(
|
|
5783
|
+
const path = hubPath(dirs, payload);
|
|
5784
|
+
if (path === null || !isSynced(path) || artifactOf(path) === null || existsSync(join(dirs.data, path))) return null;
|
|
5610
5785
|
return {
|
|
5611
5786
|
path,
|
|
5612
5787
|
write: tool !== "Read"
|
|
@@ -5617,8 +5792,8 @@ function planFetch(root, payload) {
|
|
|
5617
5792
|
* change then waits for the Edit's PostToolUse report (docs/sync.md "等 Edit 的回報"). Null for anything else, which
|
|
5618
5793
|
* announces nothing: a change no Edit announced is sent at once.
|
|
5619
5794
|
*/
|
|
5620
|
-
function planAnnounce(
|
|
5621
|
-
const path = hubPath(
|
|
5795
|
+
function planAnnounce(dirs, payload) {
|
|
5796
|
+
const path = hubPath(dirs, payload);
|
|
5622
5797
|
if (path === null || !isSynced(path)) return null;
|
|
5623
5798
|
const edit = editOf(path, payload);
|
|
5624
5799
|
return edit && edit.old !== "" ? edit : null;
|
|
@@ -5630,10 +5805,53 @@ const fetchDenial = (reason) => JSON.stringify({ hookSpecificOutput: {
|
|
|
5630
5805
|
permissionDecisionReason: `mainflow sync: ${reason}`
|
|
5631
5806
|
} });
|
|
5632
5807
|
//#endregion
|
|
5808
|
+
//#region ../sync/dist/oversized.js
|
|
5809
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
5810
|
+
/** The synced files under `data`, the hub's .mainflow/, by path: a walk into the directories that may hold them alone. */
|
|
5811
|
+
function syncedFiles(data) {
|
|
5812
|
+
const files = [];
|
|
5813
|
+
const visit = (dir) => {
|
|
5814
|
+
let entries;
|
|
5815
|
+
try {
|
|
5816
|
+
entries = readdirSync(join(data, dir), { withFileTypes: true });
|
|
5817
|
+
} catch {
|
|
5818
|
+
return;
|
|
5819
|
+
}
|
|
5820
|
+
for (const entry of entries) {
|
|
5821
|
+
const path = dir ? `${dir}/${entry.name}` : entry.name;
|
|
5822
|
+
if (entry.isDirectory()) {
|
|
5823
|
+
if (mayHoldSynced(path)) visit(path);
|
|
5824
|
+
} else if (entry.isFile() && isSynced(path)) files.push(path);
|
|
5825
|
+
}
|
|
5826
|
+
};
|
|
5827
|
+
visit("");
|
|
5828
|
+
return files;
|
|
5829
|
+
}
|
|
5830
|
+
/**
|
|
5831
|
+
* The synced files of the hub at `root` larger than the sync server keeps (MAX_FILE_BYTES): UTF-8 text of the sync
|
|
5832
|
+
* scope. The sync client leaves them out of sync; `mainflow check` warns of them.
|
|
5833
|
+
*/
|
|
5834
|
+
function oversizedFiles(dirs) {
|
|
5835
|
+
const data = dirs.data;
|
|
5836
|
+
const out = [];
|
|
5837
|
+
for (const path of syncedFiles(data)) {
|
|
5838
|
+
const file = join(data, path);
|
|
5839
|
+
try {
|
|
5840
|
+
if (statSync(file).size <= 19e5) continue;
|
|
5841
|
+
const bytes = textBytes(decoder.decode(readFileSync(file)));
|
|
5842
|
+
if (bytes > 19e5) out.push({
|
|
5843
|
+
path,
|
|
5844
|
+
bytes
|
|
5845
|
+
});
|
|
5846
|
+
} catch {}
|
|
5847
|
+
}
|
|
5848
|
+
return out;
|
|
5849
|
+
}
|
|
5850
|
+
//#endregion
|
|
5633
5851
|
//#region ../sync/dist/outbox.js
|
|
5634
5852
|
/** Whether the hub at `root` writes through the sync server. */
|
|
5635
|
-
function remoteMode(
|
|
5636
|
-
return readDaemon(
|
|
5853
|
+
function remoteMode(dirs, env = process.env) {
|
|
5854
|
+
return readDaemon(dirs) !== null || syncSettings(dirs, env) !== null;
|
|
5637
5855
|
}
|
|
5638
5856
|
/** Refusals of an operation by the local files, as opposed to a write lock held too long; thrown as they are. */
|
|
5639
5857
|
const refusals = /* @__PURE__ */ new WeakSet();
|
|
@@ -5644,8 +5862,8 @@ const refusedHere = (error) => error instanceof Error && refusals.has(error);
|
|
|
5644
5862
|
* client reads the outbox under the same lock, so it never sees the files this changed without the entry that
|
|
5645
5863
|
* explains them, and never takes them for an edit to send. A refusal here is thrown, and nothing is queued.
|
|
5646
5864
|
*/
|
|
5647
|
-
function queueOperation(
|
|
5648
|
-
const storage = nodeStorage(
|
|
5865
|
+
function queueOperation(dirs, request) {
|
|
5866
|
+
const storage = nodeStorage(dirs);
|
|
5649
5867
|
return storage.lock(() => {
|
|
5650
5868
|
const before = {};
|
|
5651
5869
|
const keep = (path) => {
|
|
@@ -5669,7 +5887,7 @@ function queueOperation(root, request) {
|
|
|
5669
5887
|
const written = {};
|
|
5670
5888
|
for (const change of result.changes) if (change.kind !== "makeDir" && isSynced(change.path)) written[change.path] = held.read(change.path);
|
|
5671
5889
|
const id = entryId();
|
|
5672
|
-
writeQueue(
|
|
5890
|
+
writeQueue(dirs, [...readQueue(dirs), {
|
|
5673
5891
|
id,
|
|
5674
5892
|
kind: "op",
|
|
5675
5893
|
request: pending,
|
|
@@ -5683,8 +5901,8 @@ function queueOperation(root, request) {
|
|
|
5683
5901
|
});
|
|
5684
5902
|
}
|
|
5685
5903
|
/** How many writes wait in the outbox for the server, as queue.json says: not the ones it refused (src/state.ts). */
|
|
5686
|
-
function queuedCount(
|
|
5687
|
-
const file = syncPaths(
|
|
5904
|
+
function queuedCount(dirs) {
|
|
5905
|
+
const file = syncPaths(dirs).queue;
|
|
5688
5906
|
if (!existsSync(file)) return 0;
|
|
5689
5907
|
try {
|
|
5690
5908
|
return (JSON.parse(readFileSync(file, "utf8")).entries ?? []).filter(waiting).length;
|
|
@@ -5700,8 +5918,8 @@ const NOTIFY_MS = 200;
|
|
|
5700
5918
|
* the server's answer, such as a hook or the interface. Without a sync client, or one that does not answer, nothing
|
|
5701
5919
|
* happens: the client's watcher of queue.json and its rescan find the entry, a moment later.
|
|
5702
5920
|
*/
|
|
5703
|
-
async function notifyQueued(
|
|
5704
|
-
const daemon = readDaemon(
|
|
5921
|
+
async function notifyQueued(dirs) {
|
|
5922
|
+
const daemon = readDaemon(dirs);
|
|
5705
5923
|
if (!daemon) return;
|
|
5706
5924
|
try {
|
|
5707
5925
|
await callDaemon(daemon, "/send", {}, NOTIFY_MS);
|
|
@@ -5711,12 +5929,12 @@ async function notifyQueued(root) {
|
|
|
5711
5929
|
* Ask the sync client what became of entry `id`, giving it up to `timeoutMs`. A sync client that is still starting is
|
|
5712
5930
|
* waited for. `waiting` when it is offline, or the time ran out first.
|
|
5713
5931
|
*/
|
|
5714
|
-
async function awaitOutcome(
|
|
5932
|
+
async function awaitOutcome(dirs, id, timeoutMs) {
|
|
5715
5933
|
const deadline = Date.now() + timeoutMs;
|
|
5716
5934
|
for (;;) {
|
|
5717
5935
|
const left = deadline - Date.now();
|
|
5718
5936
|
if (left <= 0) return { state: "waiting" };
|
|
5719
|
-
const daemon = readDaemon(
|
|
5937
|
+
const daemon = readDaemon(dirs);
|
|
5720
5938
|
if (daemon) try {
|
|
5721
5939
|
return await callDaemon(daemon, "/wait", {
|
|
5722
5940
|
id,
|
|
@@ -5729,6 +5947,54 @@ async function awaitOutcome(root, id, timeoutMs) {
|
|
|
5729
5947
|
}
|
|
5730
5948
|
}
|
|
5731
5949
|
//#endregion
|
|
5950
|
+
//#region src/version-gate.ts
|
|
5951
|
+
const INVALID_RANGE = "mainflow in .mainflow/config.yaml is not a valid semver range, so the mainflow version is not checked; run \"mainflow check\" for details";
|
|
5952
|
+
const READ_ENDING = "; writes will be refused until then";
|
|
5953
|
+
/**
|
|
5954
|
+
* Compare this CLI's version with the mainflow versions the hub's config.yaml takes writes from. A write outside the
|
|
5955
|
+
* range is refused; a read is warned about. A `mainflow` that is no range is only warned about, whatever the command,
|
|
5956
|
+
* since core reads it as no limit so that a typo does not shut every writer out. A hub that names no range, has no
|
|
5957
|
+
* readable config.yaml or takes this version is not checked. The comparison is core's versionMismatch, the one the
|
|
5958
|
+
* sync server makes, and its message carries upgradeCommand.
|
|
5959
|
+
*/
|
|
5960
|
+
function versionGate(dirs, version, command) {
|
|
5961
|
+
let requirements;
|
|
5962
|
+
try {
|
|
5963
|
+
requirements = hubRequirements(nodeStorage(dirs));
|
|
5964
|
+
} catch {
|
|
5965
|
+
return { kind: "run" };
|
|
5966
|
+
}
|
|
5967
|
+
if (requirements?.mainflowInvalid) return {
|
|
5968
|
+
kind: "warn",
|
|
5969
|
+
message: INVALID_RANGE
|
|
5970
|
+
};
|
|
5971
|
+
if (!requirements?.mainflow) return { kind: "run" };
|
|
5972
|
+
if (command.writes) {
|
|
5973
|
+
const message = versionMismatch(requirements.mainflow, version);
|
|
5974
|
+
return message === null ? { kind: "run" } : {
|
|
5975
|
+
kind: "refuse",
|
|
5976
|
+
message
|
|
5977
|
+
};
|
|
5978
|
+
}
|
|
5979
|
+
const message = versionMismatch(requirements.mainflow, version, READ_ENDING);
|
|
5980
|
+
return message === null ? { kind: "run" } : {
|
|
5981
|
+
kind: "warn",
|
|
5982
|
+
message
|
|
5983
|
+
};
|
|
5984
|
+
}
|
|
5985
|
+
/**
|
|
5986
|
+
* The checkpoint of every write that goes through the writer (the commands, the interface, the hooks): throws a
|
|
5987
|
+
* WriteError when `version` is outside the range the hub takes writes from, and warns on stderr about a range that is
|
|
5988
|
+
* no range. A hook's write is not checked (`hook`), since a hook has to answer the host whatever the version; the
|
|
5989
|
+
* `hook` command itself warns once before it runs.
|
|
5990
|
+
*/
|
|
5991
|
+
function requireWritableVersion(dirs, version, options = {}) {
|
|
5992
|
+
if (options.hook) return;
|
|
5993
|
+
const verdict = versionGate(dirs, version, { writes: true });
|
|
5994
|
+
if (verdict.kind === "refuse") throw new WriteError(`refusing to write: ${verdict.message}`);
|
|
5995
|
+
if (verdict.kind === "warn") console.error(`! ${verdict.message}`);
|
|
5996
|
+
}
|
|
5997
|
+
//#endregion
|
|
5732
5998
|
//#region src/remote/start.ts
|
|
5733
5999
|
/** How long a sync client started in the background may take to claim the hub, its first request to the server included. */
|
|
5734
6000
|
const STARTING_MS = 6e4;
|
|
@@ -5745,10 +6011,10 @@ const alive = (pid) => {
|
|
|
5745
6011
|
* none. `env` gives the sync server and service token as the environment variables do; for a hub other than the one this
|
|
5746
6012
|
* process was started in it is `{}`, so the process started reads that hub's own .mainflow/local/sync.json.
|
|
5747
6013
|
*/
|
|
5748
|
-
async function startSyncInBackground(
|
|
5749
|
-
const probe = await probeDaemon(
|
|
6014
|
+
async function startSyncInBackground(dirs, env = process.env) {
|
|
6015
|
+
const probe = await probeDaemon(dirs);
|
|
5750
6016
|
if (probe && probe.state !== "gone") return { state: "running" };
|
|
5751
|
-
const paths = syncPaths(
|
|
6017
|
+
const paths = syncPaths(dirs);
|
|
5752
6018
|
const cli = process.argv[1];
|
|
5753
6019
|
if (process.env.MAINFLOW_NO_BACKGROUND_SYNC === "1") return {
|
|
5754
6020
|
state: "failed",
|
|
@@ -5758,10 +6024,10 @@ async function startSyncInBackground(root, env = process.env) {
|
|
|
5758
6024
|
state: "failed",
|
|
5759
6025
|
reason: "this process cannot tell where the mainflow CLI is"
|
|
5760
6026
|
};
|
|
5761
|
-
const log =
|
|
5762
|
-
return nodeStorage(
|
|
5763
|
-
const reused = probe?.state === "gone" && forgetDaemon(
|
|
5764
|
-
if (reused === void 0 && readDaemon(
|
|
6027
|
+
const log = paths.log;
|
|
6028
|
+
return nodeStorage(dirs).lock(() => {
|
|
6029
|
+
const reused = probe?.state === "gone" && forgetDaemon(dirs, probe.daemon) ? probe.daemon.pid : void 0;
|
|
6030
|
+
if (reused === void 0 && readDaemon(dirs)) return { state: "starting" };
|
|
5765
6031
|
let previous = null;
|
|
5766
6032
|
try {
|
|
5767
6033
|
previous = existsSync(paths.started) ? JSON.parse(readFileSync(paths.started, "utf8")) : null;
|
|
@@ -5775,7 +6041,7 @@ async function startSyncInBackground(root, env = process.env) {
|
|
|
5775
6041
|
let pid;
|
|
5776
6042
|
try {
|
|
5777
6043
|
const child = spawn(process.execPath, [cli, "sync"], {
|
|
5778
|
-
cwd: root,
|
|
6044
|
+
cwd: dirs.root,
|
|
5779
6045
|
detached: true,
|
|
5780
6046
|
stdio: [
|
|
5781
6047
|
"ignore",
|
|
@@ -5817,8 +6083,8 @@ async function startSyncInBackground(root, env = process.env) {
|
|
|
5817
6083
|
/** Added to a write the local files refused, unless the sync client runs with the server's latest state. */
|
|
5818
6084
|
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)";
|
|
5819
6085
|
/** Whether the hub's sync client runs and has the server's latest state, asked with a short wait. */
|
|
5820
|
-
async function caughtUp(
|
|
5821
|
-
const daemon = readDaemon(
|
|
6086
|
+
async function caughtUp(dirs) {
|
|
6087
|
+
const daemon = readDaemon(dirs);
|
|
5822
6088
|
if (!daemon) return false;
|
|
5823
6089
|
try {
|
|
5824
6090
|
return (await callDaemon(daemon, "/status", void 0, 2e3)).caughtUp === true;
|
|
@@ -5844,29 +6110,30 @@ function writtenLines(written) {
|
|
|
5844
6110
|
* other than the one this process was started in, such as another hub the interface shows, passes `{}`, so only its
|
|
5845
6111
|
* own .mainflow/local/sync.json and sync client count.
|
|
5846
6112
|
*/
|
|
5847
|
-
function hubWriter(
|
|
6113
|
+
function hubWriter(dirs, env = process.env, options = {}) {
|
|
5848
6114
|
const startSync = options.startSync ?? startSyncInBackground;
|
|
5849
6115
|
return async (op, input) => {
|
|
5850
|
-
|
|
5851
|
-
|
|
6116
|
+
requireWritableVersion(dirs, CLI_VERSION, { hook: options.hook });
|
|
6117
|
+
if (!remoteMode(dirs, env)) return {
|
|
6118
|
+
value: applyOperation(nodeStorage(dirs), op, input, { now: nowMinute() }).value,
|
|
5852
6119
|
queued: false,
|
|
5853
6120
|
renumbered: [],
|
|
5854
6121
|
notices: []
|
|
5855
6122
|
};
|
|
5856
6123
|
let queuedOp;
|
|
5857
6124
|
try {
|
|
5858
|
-
queuedOp = queueOperation(
|
|
6125
|
+
queuedOp = queueOperation(dirs, {
|
|
5859
6126
|
protocol: 3,
|
|
5860
6127
|
op,
|
|
5861
6128
|
input,
|
|
5862
6129
|
now: requestTime()
|
|
5863
6130
|
});
|
|
5864
6131
|
} catch (error) {
|
|
5865
|
-
if (refusedHere(error) && !await caughtUp(
|
|
6132
|
+
if (refusedHere(error) && !await caughtUp(dirs)) error.message += `; ${STALE_COPY}`;
|
|
5866
6133
|
throw error;
|
|
5867
6134
|
}
|
|
5868
6135
|
const { id, result } = queuedOp;
|
|
5869
|
-
const start = await startSync(
|
|
6136
|
+
const start = await startSync(dirs, env);
|
|
5870
6137
|
const queued = {
|
|
5871
6138
|
value: result.value,
|
|
5872
6139
|
queued: true,
|
|
@@ -5879,10 +6146,10 @@ function hubWriter(root, env = process.env, options = {}) {
|
|
|
5879
6146
|
stopped: `mainflow sync is not running (${start.reason}); the write waits in .mainflow/local/sync/queue.json until it runs`
|
|
5880
6147
|
};
|
|
5881
6148
|
if (options.confirmMs === void 0) {
|
|
5882
|
-
await notifyQueued(
|
|
6149
|
+
await notifyQueued(dirs);
|
|
5883
6150
|
return queued;
|
|
5884
6151
|
}
|
|
5885
|
-
const outcome = await awaitOutcome(
|
|
6152
|
+
const outcome = await awaitOutcome(dirs, id, options.confirmMs);
|
|
5886
6153
|
if (outcome.state === "applied") {
|
|
5887
6154
|
const { response } = outcome;
|
|
5888
6155
|
return {
|
|
@@ -5902,4 +6169,4 @@ function hubWriter(root, env = process.env, options = {}) {
|
|
|
5902
6169
|
};
|
|
5903
6170
|
}
|
|
5904
6171
|
//#endregion
|
|
5905
|
-
export {
|
|
6172
|
+
export { hubName as A, readRemote as C, Unreachable as D, ServerError as E, syncPaths as F, SETTINGS_FILE as M, SYNC_DIR as N, ENV as O, isSynced as P, watchTree as S, ServerApi as T, DaemonUnavailable as _, versionGate as a, readDaemon as b, oversizedFiles as c, planAnnounce as d, planFetch as f, DaemonRunning as g, startDaemon as h, requireWritableVersion as i, syncSettings as j, earlierToken as k, fetchDenial as l, toolCallOf as m, hubWriter as n, queuedCount as o, planFlush as p, writtenLines as r, remoteMode as s, CONFIRM_MS as t, hookAnswer as u, callDaemon as v, HubRewritingError as w, silentHint as x, probeDaemon as y };
|