@stll/folio-cli 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -2
- package/dist/cli.js +11 -0
- package/dist/document.d.ts +2 -16
- package/dist/document.js +1 -14
- package/dist/editor-lease.d.ts +114 -0
- package/dist/editor-lease.js +294 -0
- package/dist/execute-read.d.ts +3 -1
- package/dist/execute-read.js +1 -1
- package/dist/execute-write.d.ts +29 -1
- package/dist/execute-write.js +111 -26
- package/dist/file-system.d.ts +23 -0
- package/dist/file-system.js +23 -0
- package/dist/journal.d.ts +17 -1
- package/dist/journal.js +37 -2
- package/dist/lock.d.ts +25 -2
- package/dist/lock.js +133 -27
- package/dist/output-file.d.ts +2 -1
- package/dist/output-file.js +2 -2
- package/dist/package.js +1 -1
- package/dist/save.d.ts +48 -0
- package/dist/save.js +354 -0
- package/dist/serve.js +42 -26
- package/dist/sidecar.d.ts +1 -1
- package/dist/sidecar.js +1 -1
- package/dist/transaction.d.ts +2 -1
- package/dist/transaction.js +2 -1
- package/package.json +11 -3
package/dist/execute-write.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
import { checkExpectedVersion,
|
|
1
|
+
import { checkExpectedVersion, openReviewer, readDocumentFile } from "./document.js";
|
|
2
|
+
import { acquireLeaseForWrite } from "./editor-lease.js";
|
|
2
3
|
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { blockIdSources } from "./execute-read.js";
|
|
5
|
+
import { errnoCode, fileVersionOf, sameFile } from "./file-system.js";
|
|
6
|
+
import { findCommit, journalPathFor, latestCommitFor, recoverStages } from "./journal.js";
|
|
5
7
|
import { diffPackages, nextRevisionIdSeed } from "./package-parts.js";
|
|
6
|
-
import { SIDECAR_DIRECTORY, unsafePath } from "./sidecar.js";
|
|
8
|
+
import { SIDECAR_DIRECTORY, inspectPath, readSidecarFile, unsafePath } from "./sidecar.js";
|
|
7
9
|
import { commitTransaction } from "./transaction.js";
|
|
8
10
|
import { Result, panic } from "better-result";
|
|
9
11
|
import { createReviewerBridge } from "@stll/folio-agents/bridges/reviewer";
|
|
10
12
|
import { generateRedlineDocx } from "@stll/folio-agents/compare";
|
|
11
13
|
import { executeFolioToolCallUntyped } from "@stll/folio-agents/execute";
|
|
12
14
|
import { FOLIO_AGENT_TOOL_NAMES } from "@stll/folio-agents/types";
|
|
13
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
14
15
|
import { realpath, stat } from "node:fs/promises";
|
|
15
16
|
import path from "node:path";
|
|
17
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
16
18
|
//#region src/execute-write.ts
|
|
17
19
|
/**
|
|
18
|
-
* Run one mutating tool call as a transaction: take the destination's lease
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* Run one mutating tool call as a transaction: take the destination's lease
|
|
21
|
+
* (asking an editor that holds it to save and release first), settle any
|
|
22
|
+
* crashed stage, replay a committed `txId`, apply the change to a fresh
|
|
23
|
+
* reviewer, save it (selective unless a full repack is allowed), and commit
|
|
24
|
+
* the bytes atomically with a journal line and, in place, a backup.
|
|
22
25
|
*/
|
|
23
26
|
const TX_ID_PATTERN = /^[\w.-]{1,128}$/u;
|
|
24
27
|
const invalidInput = (message, hint) => cliError({
|
|
@@ -224,6 +227,73 @@ const mutate = (options) => {
|
|
|
224
227
|
}
|
|
225
228
|
}
|
|
226
229
|
};
|
|
230
|
+
/** The `tool` an editor's whole-package save journals (`save.ts`). */
|
|
231
|
+
const EDITOR_SAVE_TOOL = "editor_save";
|
|
232
|
+
const BLOCK_ID_KEY = /blockId$/iu;
|
|
233
|
+
/** Every block id an operation targets: any `...blockId` string, at any depth. */
|
|
234
|
+
const targetedBlockIds = (value, found = /* @__PURE__ */ new Set()) => {
|
|
235
|
+
if (Array.isArray(value)) for (const item of value) targetedBlockIds(item, found);
|
|
236
|
+
else if (isRecord(value)) for (const [key, entry] of Object.entries(value)) if (BLOCK_ID_KEY.test(key) && typeof entry === "string") found.add(entry);
|
|
237
|
+
else targetedBlockIds(entry, found);
|
|
238
|
+
return found;
|
|
239
|
+
};
|
|
240
|
+
/** The ids of the blocks whose id is the package's own `w14:paraId`. */
|
|
241
|
+
const durableBlockIds = async (file) => {
|
|
242
|
+
const reviewer = await openReviewer(file);
|
|
243
|
+
if (reviewer.isErr()) return null;
|
|
244
|
+
const durable = /* @__PURE__ */ new Set();
|
|
245
|
+
for (const [id, source] of blockIdSources(reviewer.value)) if (source === "package") durable.add(id);
|
|
246
|
+
return durable;
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* The version the caller read, from the backup the editor's save made of
|
|
250
|
+
* it; `null` when it is gone or no longer holds that version.
|
|
251
|
+
*/
|
|
252
|
+
const readFlushedFrom = async (documentPath, version) => {
|
|
253
|
+
const backupPath = path.join(path.dirname(documentPath), SIDECAR_DIRECTORY, "backups", path.basename(documentPath), `${version}.docx`);
|
|
254
|
+
const entry = await inspectPath(backupPath);
|
|
255
|
+
if (entry.isErr() || entry.value.type !== "file") return null;
|
|
256
|
+
const bytes = await readSidecarFile(backupPath);
|
|
257
|
+
if (bytes.isErr() || fileVersionOf(bytes.value) !== version) return null;
|
|
258
|
+
return {
|
|
259
|
+
path: backupPath,
|
|
260
|
+
bytes: bytes.value,
|
|
261
|
+
fileVersion: version,
|
|
262
|
+
identity: entry.value.identity,
|
|
263
|
+
links: entry.value.links
|
|
264
|
+
};
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* Whether a write whose `fileVersion` went stale only because the editor
|
|
268
|
+
* holding the lease saved when asked may run on the saved version. It may
|
|
269
|
+
* when it is an in-place agent tool, the version it read was current when
|
|
270
|
+
* the flush was requested, the journal's newest commit is the editor's save
|
|
271
|
+
* from exactly that version to the file as it is now, and every block the
|
|
272
|
+
* call targets is named by a durable `w14:paraId` both in the version it
|
|
273
|
+
* read and in the saved one. A text- or position-derived id could name
|
|
274
|
+
* another paragraph once the user's edits moved things, so such a call, or
|
|
275
|
+
* one that targets no block, stays `stale_version` and the caller re-reads.
|
|
276
|
+
* Text-hash preconditions are then re-checked against the saved version.
|
|
277
|
+
*/
|
|
278
|
+
const rebasedOntoFlush = async ({ tool, flush, inPlace, source, args, expected }) => {
|
|
279
|
+
if (flush.type !== "flushed" || tool.type !== "agentWrite" || !inPlace) return null;
|
|
280
|
+
if (expected === void 0 || expected !== flush.versionBefore) return null;
|
|
281
|
+
const latest = await latestCommitFor(source.path);
|
|
282
|
+
if (latest === null || latest.tool !== "editor_save" || latest.fromVersion !== expected || latest.toVersion !== source.fileVersion) return null;
|
|
283
|
+
const targets = targetedBlockIds(args);
|
|
284
|
+
if (targets.size === 0) return null;
|
|
285
|
+
const read = await readFlushedFrom(source.path, expected);
|
|
286
|
+
if (read === null) return null;
|
|
287
|
+
for (const file of [read, source]) {
|
|
288
|
+
const durable = await durableBlockIds(file);
|
|
289
|
+
if (durable === null || [...targets].some((id) => !durable.has(id))) return null;
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
fromVersion: expected,
|
|
293
|
+
toVersion: source.fileVersion,
|
|
294
|
+
flushedBy: flush.holder.owner
|
|
295
|
+
};
|
|
296
|
+
};
|
|
227
297
|
const DOCX_NAME = /\.docx$/iu;
|
|
228
298
|
const invalidDestination = (message, hint) => cliError({
|
|
229
299
|
code: FOLIO_CLI_ERROR_CODES.invalidDestination,
|
|
@@ -296,11 +366,11 @@ const identityOfInput = async (inputPath) => {
|
|
|
296
366
|
* that is the input under another name (a symlink, a hard link, a
|
|
297
367
|
* case-insensitive alias): files are compared by device and inode.
|
|
298
368
|
*/
|
|
299
|
-
const resolveTarget = async (
|
|
300
|
-
const source = await identityOfInput(
|
|
369
|
+
const resolveTarget = async ({ sourcePath, destination, redline }) => {
|
|
370
|
+
const source = await identityOfInput(sourcePath);
|
|
301
371
|
if (source.isErr()) return Result.err(source.error);
|
|
302
372
|
if (destination.type === "inPlace") {
|
|
303
|
-
if (
|
|
373
|
+
if (redline !== void 0) return Result.err(cliError({
|
|
304
374
|
code: FOLIO_CLI_ERROR_CODES.usage,
|
|
305
375
|
message: "A redline is written to a new file, never in place.",
|
|
306
376
|
hint: "Pass -o <redline.docx>."
|
|
@@ -316,14 +386,13 @@ const resolveTarget = async (tool, call, destination) => {
|
|
|
316
386
|
const target = await resolveWritePath(destination.path);
|
|
317
387
|
if (target.isErr()) return Result.err(target.error);
|
|
318
388
|
const inputs = [source.value];
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const revised = await identityOfInput(revisedPath);
|
|
389
|
+
if (redline !== void 0 && typeof redline.revisedPath === "string") {
|
|
390
|
+
const revised = await identityOfInput(redline.revisedPath);
|
|
322
391
|
if (revised.isErr()) return Result.err(revised.error);
|
|
323
392
|
inputs.push(revised.value);
|
|
324
393
|
}
|
|
325
394
|
const clash = inputs.find(({ path: inputPath, identity }) => inputPath === target.value.path || target.value.identity !== null && sameFile(identity, target.value.identity));
|
|
326
|
-
if (clash !== void 0) return Result.err(invalidDestination(`The destination is the input ${clash.path}.`,
|
|
395
|
+
if (clash !== void 0) return Result.err(invalidDestination(`The destination is the input ${clash.path}.`, redline !== void 0 ? "Write the redline to a new file." : "Use --in-place to change the file itself; it keeps a backup."));
|
|
327
396
|
return Result.ok({
|
|
328
397
|
sourcePath: source.value.path,
|
|
329
398
|
sourceIdentity: source.value.identity,
|
|
@@ -335,16 +404,23 @@ const executeWriteTool = async (tool, call, options) => {
|
|
|
335
404
|
if (options.txId !== void 0 && !TX_ID_PATTERN.test(options.txId)) return Result.err(invalidInput("txId must be 1 to 128 letters, digits, '.', '_' or '-'."));
|
|
336
405
|
if (options.sourcePrecondition === "required" && call.fileVersion === void 0) return Result.err(invalidInput("A change needs the fileVersion of the file it was read from.", "Pass --expect-version <fileVersion> from your read (MCP: fileVersion), or --no-expect-version to skip the check."));
|
|
337
406
|
const txId = options.txId ?? randomUUID();
|
|
338
|
-
const target = await resolveTarget(
|
|
407
|
+
const target = await resolveTarget({
|
|
408
|
+
sourcePath: call.path,
|
|
409
|
+
destination: options.destination,
|
|
410
|
+
...tool.type === "compare" && { redline: { revisedPath: call.args["revisedPath"] } }
|
|
411
|
+
});
|
|
339
412
|
if (target.isErr()) return Result.err(target.error);
|
|
340
413
|
const { sourcePath, sourceIdentity, destinationPath } = target.value;
|
|
341
414
|
const journalPath = journalPathFor(destinationPath, options.journalPath);
|
|
342
|
-
const
|
|
415
|
+
const inPlace = destinationPath === sourcePath;
|
|
416
|
+
const acquired = await acquireLeaseForWrite({
|
|
343
417
|
documentPath: destinationPath,
|
|
344
418
|
txId,
|
|
345
|
-
force: options.force
|
|
419
|
+
force: options.force,
|
|
420
|
+
...options.flushWaitMs !== void 0 && { flushWaitMs: options.flushWaitMs }
|
|
346
421
|
});
|
|
347
|
-
if (
|
|
422
|
+
if (acquired.isErr()) return Result.err(acquired.error);
|
|
423
|
+
const { lease, flush } = acquired.value;
|
|
348
424
|
try {
|
|
349
425
|
const recovered = await recoverStages({
|
|
350
426
|
documentPath: destinationPath,
|
|
@@ -375,11 +451,19 @@ const executeWriteTool = async (tool, call, options) => {
|
|
|
375
451
|
}
|
|
376
452
|
const source = await readDocumentFile(sourcePath);
|
|
377
453
|
if (source.isErr()) return Result.err(source.error);
|
|
378
|
-
const expectedIdentity =
|
|
454
|
+
const expectedIdentity = inPlace && (flush.type !== "notAsked" || recovered.value.some(({ action }) => action === "rolledForward")) ? source.value.identity : sourceIdentity;
|
|
379
455
|
if (!sameFile(source.value.identity, expectedIdentity)) return Result.err(unsafePath(`${sourcePath} was replaced while the write started.`));
|
|
380
|
-
if (
|
|
456
|
+
if (inPlace && source.value.links > 1) return Result.err(unsafePath(`${sourcePath} has other hard links; an in-place write would detach them.`, "Write to a new file with -o instead."));
|
|
381
457
|
const version = checkExpectedVersion(source.value, call.fileVersion);
|
|
382
|
-
|
|
458
|
+
const rebased = version.isErr() ? await rebasedOntoFlush({
|
|
459
|
+
tool,
|
|
460
|
+
flush,
|
|
461
|
+
inPlace,
|
|
462
|
+
source: source.value,
|
|
463
|
+
args: call.args,
|
|
464
|
+
expected: call.fileVersion
|
|
465
|
+
}) : null;
|
|
466
|
+
if (version.isErr() && rebased === null) return Result.err(recovered.value.length === 0 ? version.error : cliError({
|
|
383
467
|
code: version.error.code,
|
|
384
468
|
message: version.error.message,
|
|
385
469
|
hint: version.error.hint,
|
|
@@ -415,6 +499,7 @@ const executeWriteTool = async (tool, call, options) => {
|
|
|
415
499
|
...repackReason !== void 0 && { repackReason },
|
|
416
500
|
changedParts: changedParts.value,
|
|
417
501
|
...recovered.value.length > 0 && { recovered: recovered.value },
|
|
502
|
+
...rebased !== null && { rebased },
|
|
418
503
|
result
|
|
419
504
|
};
|
|
420
505
|
const committed = await commitTransaction({
|
|
@@ -425,7 +510,7 @@ const executeWriteTool = async (tool, call, options) => {
|
|
|
425
510
|
sourceIdentity: expectedIdentity,
|
|
426
511
|
fromVersion: source.value.fileVersion,
|
|
427
512
|
bytes,
|
|
428
|
-
verifyLease: lease.
|
|
513
|
+
verifyLease: lease.verify,
|
|
429
514
|
changedParts: changedParts.value,
|
|
430
515
|
journalPath,
|
|
431
516
|
entry: {
|
|
@@ -442,8 +527,8 @@ const executeWriteTool = async (tool, call, options) => {
|
|
|
442
527
|
});
|
|
443
528
|
return committed.isErr() ? Result.err(committed.error) : Result.ok(committed.value.receipt);
|
|
444
529
|
} finally {
|
|
445
|
-
await lease.
|
|
530
|
+
await lease.release();
|
|
446
531
|
}
|
|
447
532
|
};
|
|
448
533
|
//#endregion
|
|
449
|
-
export { executeWriteTool };
|
|
534
|
+
export { EDITOR_SAVE_TOOL, TX_ID_PATTERN, canonicalJson, executeWriteTool, resolveTarget };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/file-system.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* File-system primitives shared by the document reader and the sidecar
|
|
4
|
+
* files. Kept free of the document model so `editor-lease.ts` (which an
|
|
5
|
+
* editor host bundles) does not pull in the parser.
|
|
6
|
+
*/
|
|
7
|
+
/** A file's version: the lowercase hex SHA-256 of its bytes. */
|
|
8
|
+
declare const fileVersionOf: (bytes: Uint8Array) => string;
|
|
9
|
+
/** Which file a path named when it was read: device and inode. */
|
|
10
|
+
type FileIdentity = {
|
|
11
|
+
dev: number;
|
|
12
|
+
ino: number;
|
|
13
|
+
};
|
|
14
|
+
declare const sameFile: (left: FileIdentity, right: FileIdentity) => boolean;
|
|
15
|
+
/**
|
|
16
|
+
* `O_NOFOLLOW` where the platform has it: opening a path whose last
|
|
17
|
+
* component is a symlink fails instead of following it.
|
|
18
|
+
*/
|
|
19
|
+
declare const NO_FOLLOW: number;
|
|
20
|
+
/** The `code` of a Node.js file-system error, when there is one. */
|
|
21
|
+
declare const errnoCode: (error: unknown) => string | undefined;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { FileIdentity, NO_FOLLOW, errnoCode, fileVersionOf, sameFile };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
//#region src/file-system.ts
|
|
4
|
+
/**
|
|
5
|
+
* File-system primitives shared by the document reader and the sidecar
|
|
6
|
+
* files. Kept free of the document model so `editor-lease.ts` (which an
|
|
7
|
+
* editor host bundles) does not pull in the parser.
|
|
8
|
+
*/
|
|
9
|
+
/** A file's version: the lowercase hex SHA-256 of its bytes. */
|
|
10
|
+
const fileVersionOf = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
11
|
+
const sameFile = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
|
12
|
+
/**
|
|
13
|
+
* `O_NOFOLLOW` where the platform has it: opening a path whose last
|
|
14
|
+
* component is a symlink fails instead of following it.
|
|
15
|
+
*/
|
|
16
|
+
const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
17
|
+
/** The `code` of a Node.js file-system error, when there is one. */
|
|
18
|
+
const errnoCode = (error) => {
|
|
19
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return;
|
|
20
|
+
return typeof error.code === "string" ? error.code : void 0;
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { NO_FOLLOW, errnoCode, fileVersionOf, sameFile };
|
package/dist/journal.d.ts
CHANGED
|
@@ -56,6 +56,22 @@ declare const appendJournal: (journalPath: string, entry: JournalEntry) => Promi
|
|
|
56
56
|
* A journal (or `.folio`) that is a symlink or hard link is refused.
|
|
57
57
|
*/
|
|
58
58
|
declare const findCommit: (journalPath: string, txId: string) => Promise<Result<CommittedTransaction | undefined, FolioCliError>>;
|
|
59
|
+
/** The newest commit the journal records for one document. */
|
|
60
|
+
type LatestCommit = {
|
|
61
|
+
txId: string;
|
|
62
|
+
tool: string;
|
|
63
|
+
author: string;
|
|
64
|
+
time: string;
|
|
65
|
+
fromVersion: string;
|
|
66
|
+
toVersion: string;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* The newest commit to `documentPath` in its journal (default
|
|
70
|
+
* `.folio/journal.jsonl` beside it) that recovery did not discard, or
|
|
71
|
+
* `null`. Reads only through a plain `.folio` directory and never creates
|
|
72
|
+
* one, so a read-only preview can call it.
|
|
73
|
+
*/
|
|
74
|
+
declare const latestCommitFor: (documentPath: string, journalPath?: string) => Promise<LatestCommit | null>;
|
|
59
75
|
declare const stagePrefixFor: (documentPath: string) => string;
|
|
60
76
|
declare const stagePathFor: (documentPath: string, txId: string) => string;
|
|
61
77
|
type RecoveryAction = {
|
|
@@ -79,4 +95,4 @@ type RecoverStagesOptions = {
|
|
|
79
95
|
*/
|
|
80
96
|
declare const recoverStages: ({ documentPath, journalPath, now }: RecoverStagesOptions) => Promise<Result<RecoveryAction[], FolioCliError>>;
|
|
81
97
|
//#endregion
|
|
82
|
-
export { CommittedTransaction, JournalCommit, JournalEntry, JournalRecovery, RECOVERY_DISCARD_REASONS, RecoveryAction, RecoveryDiscardReason, appendJournal, findCommit, journalPathFor, recoverStages, stagePathFor, stagePrefixFor };
|
|
98
|
+
export { CommittedTransaction, JournalCommit, JournalEntry, JournalRecovery, LatestCommit, RECOVERY_DISCARD_REASONS, RecoveryAction, RecoveryDiscardReason, appendJournal, findCommit, journalPathFor, latestCommitFor, recoverStages, stagePathFor, stagePrefixFor };
|
package/dist/journal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { errnoCode, fileVersionOf, sameFile } from "./document.js";
|
|
2
1
|
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
|
+
import { errnoCode, fileVersionOf, sameFile } from "./file-system.js";
|
|
3
3
|
import { checkPackageIntegrity } from "./package-parts.js";
|
|
4
4
|
import { SIDECAR_DIRECTORY, ensureSidecarParent, inspectPath, openSidecarFile, readSidecarFile, syncDirectory } from "./sidecar.js";
|
|
5
5
|
import { Result } from "better-result";
|
|
@@ -98,6 +98,41 @@ const findCommit = async (journalPath, txId) => {
|
|
|
98
98
|
}
|
|
99
99
|
return Result.ok(found);
|
|
100
100
|
};
|
|
101
|
+
const parseLatest = (value) => {
|
|
102
|
+
const { txId, tool, author, time, fromVersion, toVersion } = value;
|
|
103
|
+
return typeof txId === "string" && typeof tool === "string" && typeof author === "string" && typeof time === "string" && typeof fromVersion === "string" && typeof toVersion === "string" ? {
|
|
104
|
+
txId,
|
|
105
|
+
tool,
|
|
106
|
+
author,
|
|
107
|
+
time,
|
|
108
|
+
fromVersion,
|
|
109
|
+
toVersion
|
|
110
|
+
} : null;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The newest commit to `documentPath` in its journal (default
|
|
114
|
+
* `.folio/journal.jsonl` beside it) that recovery did not discard, or
|
|
115
|
+
* `null`. Reads only through a plain `.folio` directory and never creates
|
|
116
|
+
* one, so a read-only preview can call it.
|
|
117
|
+
*/
|
|
118
|
+
const latestCommitFor = async (documentPath, journalPath) => {
|
|
119
|
+
const journal = journalPathFor(documentPath, journalPath);
|
|
120
|
+
const parent = await inspectPath(path.dirname(journal));
|
|
121
|
+
if (parent.isErr() || parent.value.type !== "directory") return null;
|
|
122
|
+
const bytes = await readSidecarFile(journal);
|
|
123
|
+
if (bytes.isErr()) return null;
|
|
124
|
+
const lines = new TextDecoder().decode(bytes.value).split("\n");
|
|
125
|
+
const discarded = /* @__PURE__ */ new Set();
|
|
126
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
127
|
+
const parsed = Result.try(() => JSON.parse(lines[index] ?? ""));
|
|
128
|
+
if (!parsed.isOk() || !isRecord(parsed.value)) continue;
|
|
129
|
+
const entry = parsed.value;
|
|
130
|
+
if (entry["path"] !== documentPath) continue;
|
|
131
|
+
if (entry["type"] === "recovery" && entry["action"] === "discarded") discarded.add(entry["txId"]);
|
|
132
|
+
else if (entry["type"] === "commit" && !discarded.has(entry["txId"])) return parseLatest(entry);
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
};
|
|
101
136
|
const stagePrefixFor = (documentPath) => `.${path.basename(documentPath)}.folio-stage-`;
|
|
102
137
|
const stagePathFor = (documentPath, txId) => path.join(path.dirname(documentPath), `${stagePrefixFor(documentPath)}${txId}`);
|
|
103
138
|
/** The destination's current version, read without following a symlink; `null` when missing. */
|
|
@@ -209,4 +244,4 @@ const recoverStages = async ({ documentPath, journalPath, now }) => {
|
|
|
209
244
|
return Result.ok(actions);
|
|
210
245
|
};
|
|
211
246
|
//#endregion
|
|
212
|
-
export { RECOVERY_DISCARD_REASONS, appendJournal, findCommit, journalPathFor, recoverStages, stagePathFor, stagePrefixFor };
|
|
247
|
+
export { RECOVERY_DISCARD_REASONS, appendJournal, findCommit, journalPathFor, latestCommitFor, recoverStages, stagePathFor, stagePrefixFor };
|
package/dist/lock.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { Result } from "better-result";
|
|
|
3
3
|
//#region src/lock.d.ts
|
|
4
4
|
/** How long a command-line transaction's lease lasts without renewal. */
|
|
5
5
|
declare const TRANSACTION_LEASE_MS: number;
|
|
6
|
+
/** The owner a lease records when the caller names none. */
|
|
7
|
+
declare const DEFAULT_LEASE_OWNER = "folio-cli";
|
|
8
|
+
/** A lease owner is 1 to 64 letters, digits, '.', '_' or '-'. */
|
|
9
|
+
declare const isLeaseOwner: (value: string) => boolean;
|
|
6
10
|
type LockHolder = {
|
|
7
11
|
owner: string;
|
|
8
12
|
pid: number;
|
|
@@ -12,6 +16,8 @@ type LockHolder = {
|
|
|
12
16
|
token: string;
|
|
13
17
|
acquiredAt: string;
|
|
14
18
|
expiresAt: string;
|
|
19
|
+
/** The holder saves and releases when a write asks it to (`editor-lease.ts`). */
|
|
20
|
+
acceptsFlush?: true;
|
|
15
21
|
};
|
|
16
22
|
declare const lockPathFor: (documentPath: string) => string;
|
|
17
23
|
/** What is beside the document: no lock, or a lock with its parsed holder when it has one. */
|
|
@@ -34,9 +40,12 @@ declare const isStaleLease: (state: LeaseState, now: Date) => boolean;
|
|
|
34
40
|
/** The lease currently beside `documentPath`. A symlinked lock reads as unreadable. */
|
|
35
41
|
declare const readLease: (documentPath: string) => Promise<LeaseState>;
|
|
36
42
|
type AcquiredLease = {
|
|
43
|
+
/** The lock's contents as last written by this handle. */
|
|
37
44
|
holder: LockHolder;
|
|
38
45
|
/** Refuse unless the lock still carries this lease's token. */
|
|
39
46
|
verify: () => Promise<Result<void, FolioCliError>>;
|
|
47
|
+
/** Push the expiry a lease length past `now`; refuses once the lease was taken over. */
|
|
48
|
+
renew: (now?: Date) => Promise<Result<LockHolder, FolioCliError>>;
|
|
40
49
|
release: () => Promise<void>;
|
|
41
50
|
};
|
|
42
51
|
type AcquireLeaseOptions = {
|
|
@@ -44,12 +53,26 @@ type AcquireLeaseOptions = {
|
|
|
44
53
|
txId: string;
|
|
45
54
|
/** Take the lease over even when another live holder has it. */
|
|
46
55
|
force: boolean;
|
|
56
|
+
/** Who holds it (default `folio-cli`), shown to a writer the lease refuses. */
|
|
57
|
+
owner?: string;
|
|
58
|
+
/** How long the lease lasts without renewal (default {@link TRANSACTION_LEASE_MS}). */
|
|
59
|
+
leaseMs?: number;
|
|
60
|
+
/** Advertise that the holder answers flush requests. */
|
|
61
|
+
acceptsFlush?: boolean;
|
|
47
62
|
now?: Date;
|
|
48
63
|
};
|
|
64
|
+
/** `.<name>.folio-lock.swap`: held for the instant one process changes an existing lock. */
|
|
65
|
+
declare const lockSwapPathFor: (documentPath: string) => string;
|
|
66
|
+
/**
|
|
67
|
+
* Act under a lease another call acquired, named by its token: `folio save
|
|
68
|
+
* --lease-token` from the editor that holds it. Releasing stays with that
|
|
69
|
+
* holder, so this handle's `release` does nothing.
|
|
70
|
+
*/
|
|
71
|
+
declare const adoptLease: (documentPath: string, token: string) => Promise<Result<AcquiredLease, FolioCliError>>;
|
|
49
72
|
/**
|
|
50
73
|
* Take the lease for one transaction. A stale lease is replaced; a live or
|
|
51
74
|
* unreadable one refuses with `locked` unless `force`.
|
|
52
75
|
*/
|
|
53
|
-
declare const acquireLease: ({ documentPath, txId, force, now }: AcquireLeaseOptions) => Promise<Result<AcquiredLease, FolioCliError>>;
|
|
76
|
+
declare const acquireLease: ({ documentPath, txId, force, owner, leaseMs, acceptsFlush, now }: AcquireLeaseOptions) => Promise<Result<AcquiredLease, FolioCliError>>;
|
|
54
77
|
//#endregion
|
|
55
|
-
export { AcquiredLease, LeaseState, LockHolder, TRANSACTION_LEASE_MS, acquireLease, isStaleLease, lockPathFor, readLease };
|
|
78
|
+
export { AcquiredLease, DEFAULT_LEASE_OWNER, LeaseState, LockHolder, TRANSACTION_LEASE_MS, acquireLease, adoptLease, isLeaseOwner, isStaleLease, lockPathFor, lockSwapPathFor, readLease };
|
package/dist/lock.js
CHANGED
|
@@ -1,33 +1,43 @@
|
|
|
1
|
-
import { errnoCode } from "./document.js";
|
|
2
1
|
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
|
+
import { errnoCode } from "./file-system.js";
|
|
3
3
|
import { inspectPath, readSidecarFile, writeNewSidecarFile } from "./sidecar.js";
|
|
4
4
|
import { Result } from "better-result";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
6
5
|
import { link, rename, rm } from "node:fs/promises";
|
|
7
6
|
import path from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
8
|
import { hostname } from "node:os";
|
|
9
|
+
import { setTimeout } from "node:timers/promises";
|
|
9
10
|
//#region src/lock.ts
|
|
10
11
|
/**
|
|
11
12
|
* Cooperative write lease: `.<name>.folio-lock` beside the document, holding
|
|
12
13
|
* the owner's pid, host, expiry, and a random token. A folio write takes it
|
|
13
14
|
* for the whole transaction and checks, just before it journals and renames,
|
|
14
15
|
* that the lock still carries its token; a writer whose lease was taken over
|
|
15
|
-
* stops there. A long-lived holder (an editor session) renews it
|
|
16
|
+
* stops there. A long-lived holder (an editor session) renews it, and marks
|
|
17
|
+
* it `acceptsFlush` when it answers the flush requests `editor-lease.ts`
|
|
18
|
+
* describes.
|
|
16
19
|
*
|
|
17
20
|
* The lock appears complete or not at all: it is written to a private
|
|
18
21
|
* temporary file and hard-linked into place, which fails when a lock exists.
|
|
22
|
+
* Changing a lock that exists (renewing, taking over, releasing, clearing a
|
|
23
|
+
* stale one) is a compare-and-swap under a short swap lock beside it.
|
|
19
24
|
* A lease is stale once it expires or, on the same host, once its process has
|
|
20
25
|
* exited. A lock that cannot be parsed is treated as held until it is older
|
|
21
26
|
* than a lease. Reads never consult it.
|
|
22
27
|
*/
|
|
23
28
|
/** How long a command-line transaction's lease lasts without renewal. */
|
|
24
29
|
const TRANSACTION_LEASE_MS = 300 * 1e3;
|
|
30
|
+
/** The owner a lease records when the caller names none. */
|
|
31
|
+
const DEFAULT_LEASE_OWNER = "folio-cli";
|
|
32
|
+
const OWNER_PATTERN = /^[\w.-]{1,64}$/u;
|
|
33
|
+
/** A lease owner is 1 to 64 letters, digits, '.', '_' or '-'. */
|
|
34
|
+
const isLeaseOwner = (value) => OWNER_PATTERN.test(value);
|
|
25
35
|
const lockPathFor = (documentPath) => path.join(path.dirname(documentPath), `.${path.basename(documentPath)}.folio-lock`);
|
|
26
36
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
37
|
const parseHolder = (text) => {
|
|
28
38
|
const parsed = Result.try(() => JSON.parse(text));
|
|
29
39
|
if (parsed.isErr() || !isRecord(parsed.value)) return null;
|
|
30
|
-
const { owner, pid, host, txId, token, acquiredAt, expiresAt } = parsed.value;
|
|
40
|
+
const { owner, pid, host, txId, token, acquiredAt, expiresAt, acceptsFlush } = parsed.value;
|
|
31
41
|
return typeof owner === "string" && typeof pid === "number" && typeof host === "string" && typeof txId === "string" && typeof token === "string" && typeof acquiredAt === "string" && typeof expiresAt === "string" ? {
|
|
32
42
|
owner,
|
|
33
43
|
pid,
|
|
@@ -35,7 +45,8 @@ const parseHolder = (text) => {
|
|
|
35
45
|
txId,
|
|
36
46
|
token,
|
|
37
47
|
acquiredAt,
|
|
38
|
-
expiresAt
|
|
48
|
+
expiresAt,
|
|
49
|
+
...acceptsFlush === true && { acceptsFlush: true }
|
|
39
50
|
} : null;
|
|
40
51
|
};
|
|
41
52
|
const processIsAlive = (pid) => {
|
|
@@ -86,6 +97,11 @@ const lockedError = (documentPath, state) => cliError({
|
|
|
86
97
|
hint: "Retry after it finishes, or pass --force to take the lease over.",
|
|
87
98
|
details: state.type === "held" ? { holder: state.holder } : void 0
|
|
88
99
|
});
|
|
100
|
+
const lostError = (documentPath) => cliError({
|
|
101
|
+
code: FOLIO_CLI_ERROR_CODES.locked,
|
|
102
|
+
message: `Another process took over the write lease on ${documentPath}; nothing was written.`,
|
|
103
|
+
hint: "Re-read the document and retry."
|
|
104
|
+
});
|
|
89
105
|
const fileSystemError = (message, error) => cliError({
|
|
90
106
|
code: FOLIO_CLI_ERROR_CODES.internal,
|
|
91
107
|
message: `${message}: ${error instanceof Error ? error.message : String(error)}`
|
|
@@ -107,51 +123,141 @@ const placeLock = async (lockPath, contents, mode) => {
|
|
|
107
123
|
await rm(temporary, { force: true });
|
|
108
124
|
}
|
|
109
125
|
};
|
|
126
|
+
const encodeHolder = (holder) => new TextEncoder().encode(`${JSON.stringify(holder)}\n`);
|
|
127
|
+
/** How long a swap lock may stand before another process breaks it (its holder crashed). */
|
|
128
|
+
const SWAP_STALE_MS = 10 * 1e3;
|
|
129
|
+
/** How long to wait for another process's swap before refusing. */
|
|
130
|
+
const SWAP_WAIT_MS = 5 * 1e3;
|
|
131
|
+
const SWAP_POLL_MS = 5;
|
|
132
|
+
/** `.<name>.folio-lock.swap`: held for the instant one process changes an existing lock. */
|
|
133
|
+
const lockSwapPathFor = (documentPath) => `${lockPathFor(documentPath)}.swap`;
|
|
134
|
+
/**
|
|
135
|
+
* Run `change` holding the swap lock beside the lease. Every change to an
|
|
136
|
+
* existing lock (a renewal, a forced takeover, a release, removing a stale
|
|
137
|
+
* lock) re-reads the lock and replaces or removes it inside `change`, so the
|
|
138
|
+
* check and the change are one step to every other folio process: a renewal
|
|
139
|
+
* never overwrites a lock another holder took, and a release never removes
|
|
140
|
+
* one. Creating a lock needs no swap lock, since `link` refuses when a lock
|
|
141
|
+
* exists. The swap lock is itself created with `link`; one older than
|
|
142
|
+
* {@link SWAP_STALE_MS} is left by a crash and is broken.
|
|
143
|
+
*/
|
|
144
|
+
const underSwapLock = async (documentPath, change) => {
|
|
145
|
+
const swapPath = lockSwapPathFor(documentPath);
|
|
146
|
+
const marker = new TextEncoder().encode(`${process.pid}\n`);
|
|
147
|
+
const deadline = Date.now() + SWAP_WAIT_MS;
|
|
148
|
+
for (;;) {
|
|
149
|
+
const taken = await placeLock(swapPath, marker, "create");
|
|
150
|
+
if (taken.isErr()) return Result.err(taken.error);
|
|
151
|
+
if (taken.value.type === "placed") break;
|
|
152
|
+
const entry = await inspectPath(swapPath);
|
|
153
|
+
if (entry.isOk() && entry.value.type === "file" && entry.value.modifiedMs + SWAP_STALE_MS <= Date.now()) {
|
|
154
|
+
await rm(swapPath, { force: true });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (Date.now() >= deadline) return Result.err(cliError({
|
|
158
|
+
code: FOLIO_CLI_ERROR_CODES.locked,
|
|
159
|
+
message: `The write lease on ${documentPath} is being changed by another process.`,
|
|
160
|
+
hint: "Retry in a moment."
|
|
161
|
+
}));
|
|
162
|
+
await setTimeout(SWAP_POLL_MS);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
return await change();
|
|
166
|
+
} finally {
|
|
167
|
+
await rm(swapPath, { force: true });
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
/** Whether two reads of the lock saw the same lock. */
|
|
171
|
+
const sameLock = (left, right) => left.type === "held" && right.type === "held" && left.holder.token === right.holder.token || left.type === "unreadable" && right.type === "unreadable";
|
|
172
|
+
/** The handle on a lease whose lock carries `initial`'s token. */
|
|
173
|
+
const leaseFor = (documentPath, initial, leaseMs) => {
|
|
174
|
+
const lockPath = lockPathFor(documentPath);
|
|
175
|
+
const holds = async () => {
|
|
176
|
+
const current = await readLease(documentPath);
|
|
177
|
+
return current.type === "held" && current.holder.token === initial.token;
|
|
178
|
+
};
|
|
179
|
+
const lease = {
|
|
180
|
+
holder: initial,
|
|
181
|
+
verify: async () => await holds() ? Result.ok() : Result.err(lostError(documentPath)),
|
|
182
|
+
renew: async (now = /* @__PURE__ */ new Date()) => {
|
|
183
|
+
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
184
|
+
const renewed = {
|
|
185
|
+
...lease.holder,
|
|
186
|
+
expiresAt
|
|
187
|
+
};
|
|
188
|
+
const swapped = await underSwapLock(documentPath, async () => {
|
|
189
|
+
if (!await holds()) return Result.err(lostError(documentPath));
|
|
190
|
+
const placed = await placeLock(lockPath, encodeHolder(renewed), "replace");
|
|
191
|
+
return placed.isErr() ? Result.err(placed.error) : Result.ok();
|
|
192
|
+
});
|
|
193
|
+
if (swapped.isErr()) return Result.err(swapped.error);
|
|
194
|
+
lease.holder = renewed;
|
|
195
|
+
return Result.ok(renewed);
|
|
196
|
+
},
|
|
197
|
+
release: async () => {
|
|
198
|
+
await underSwapLock(documentPath, async () => {
|
|
199
|
+
if (await holds()) await rm(lockPath, { force: true });
|
|
200
|
+
return Result.ok();
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
return lease;
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* Act under a lease another call acquired, named by its token: `folio save
|
|
208
|
+
* --lease-token` from the editor that holds it. Releasing stays with that
|
|
209
|
+
* holder, so this handle's `release` does nothing.
|
|
210
|
+
*/
|
|
211
|
+
const adoptLease = async (documentPath, token) => {
|
|
212
|
+
const current = await readLease(documentPath);
|
|
213
|
+
if (current.type !== "held" || current.holder.token !== token) return Result.err(cliError({
|
|
214
|
+
code: FOLIO_CLI_ERROR_CODES.locked,
|
|
215
|
+
message: `${documentPath} is not held under that lease token.`,
|
|
216
|
+
hint: "The lease expired or was taken over; acquire it again and retry.",
|
|
217
|
+
details: current.type === "held" ? { holder: current.holder } : void 0
|
|
218
|
+
}));
|
|
219
|
+
const { holder } = current;
|
|
220
|
+
const leaseMs = Math.max(Date.parse(holder.expiresAt) - Date.parse(holder.acquiredAt), 0);
|
|
221
|
+
const lease = leaseFor(documentPath, holder, leaseMs);
|
|
222
|
+
lease.release = () => Promise.resolve();
|
|
223
|
+
return Result.ok(lease);
|
|
224
|
+
};
|
|
110
225
|
/**
|
|
111
226
|
* Take the lease for one transaction. A stale lease is replaced; a live or
|
|
112
227
|
* unreadable one refuses with `locked` unless `force`.
|
|
113
228
|
*/
|
|
114
|
-
const acquireLease = async ({ documentPath, txId, force, now = /* @__PURE__ */ new Date() }) => {
|
|
229
|
+
const acquireLease = async ({ documentPath, txId, force, owner = DEFAULT_LEASE_OWNER, leaseMs = TRANSACTION_LEASE_MS, acceptsFlush = false, now = /* @__PURE__ */ new Date() }) => {
|
|
115
230
|
const lockPath = lockPathFor(documentPath);
|
|
116
231
|
const holder = {
|
|
117
|
-
owner
|
|
232
|
+
owner,
|
|
118
233
|
pid: process.pid,
|
|
119
234
|
host: hostname(),
|
|
120
235
|
txId,
|
|
121
236
|
token: randomUUID(),
|
|
122
237
|
acquiredAt: now.toISOString(),
|
|
123
|
-
expiresAt: new Date(now.getTime() +
|
|
124
|
-
|
|
125
|
-
const contents = new TextEncoder().encode(`${JSON.stringify(holder)}\n`);
|
|
126
|
-
const holds = async () => {
|
|
127
|
-
const current = await readLease(documentPath);
|
|
128
|
-
return current.type === "held" && current.holder.token === holder.token;
|
|
129
|
-
};
|
|
130
|
-
const lease = {
|
|
131
|
-
holder,
|
|
132
|
-
verify: async () => await holds() ? Result.ok() : Result.err(cliError({
|
|
133
|
-
code: FOLIO_CLI_ERROR_CODES.locked,
|
|
134
|
-
message: `Another process took over the write lease on ${documentPath}; nothing was written.`,
|
|
135
|
-
hint: "Re-read the document and retry."
|
|
136
|
-
})),
|
|
137
|
-
release: async () => {
|
|
138
|
-
if (await holds()) await rm(lockPath, { force: true });
|
|
139
|
-
}
|
|
238
|
+
expiresAt: new Date(now.getTime() + leaseMs).toISOString(),
|
|
239
|
+
...acceptsFlush && { acceptsFlush: true }
|
|
140
240
|
};
|
|
241
|
+
const contents = encodeHolder(holder);
|
|
242
|
+
const lease = leaseFor(documentPath, holder, leaseMs);
|
|
141
243
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
142
244
|
const placed = await placeLock(lockPath, contents, "create");
|
|
143
245
|
if (placed.isErr()) return Result.err(placed.error);
|
|
144
246
|
if (placed.value.type === "placed") return Result.ok(lease);
|
|
145
247
|
const existing = await readLease(documentPath);
|
|
146
248
|
if (force) {
|
|
147
|
-
const replaced = await placeLock(lockPath, contents, "replace");
|
|
249
|
+
const replaced = await underSwapLock(documentPath, () => placeLock(lockPath, contents, "replace"));
|
|
148
250
|
if (replaced.isErr()) return Result.err(replaced.error);
|
|
149
251
|
return Result.ok(lease);
|
|
150
252
|
}
|
|
151
253
|
if (!isStaleLease(existing, now)) return Result.err(lockedError(documentPath, existing));
|
|
152
|
-
await
|
|
254
|
+
const removed = await underSwapLock(documentPath, async () => {
|
|
255
|
+
if (sameLock(existing, await readLease(documentPath))) await rm(lockPath, { force: true });
|
|
256
|
+
return Result.ok();
|
|
257
|
+
});
|
|
258
|
+
if (removed.isErr()) return Result.err(removed.error);
|
|
153
259
|
}
|
|
154
260
|
return Result.err(lockedError(documentPath, { type: "free" }));
|
|
155
261
|
};
|
|
156
262
|
//#endregion
|
|
157
|
-
export { TRANSACTION_LEASE_MS, acquireLease, isStaleLease, lockPathFor, readLease };
|
|
263
|
+
export { DEFAULT_LEASE_OWNER, TRANSACTION_LEASE_MS, acquireLease, adoptLease, isLeaseOwner, isStaleLease, lockPathFor, lockSwapPathFor, readLease };
|
package/dist/output-file.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FolioCliError } from "./errors.js";
|
|
2
|
-
import { FileIdentity } from "./
|
|
2
|
+
import { FileIdentity } from "./file-system.js";
|
|
3
|
+
import "./document.js";
|
|
3
4
|
import { Result } from "better-result";
|
|
4
5
|
//#region src/output-file.d.ts
|
|
5
6
|
type WriteOutputOptions = {
|