@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.
@@ -1,24 +1,27 @@
1
- import { checkExpectedVersion, errnoCode, openReviewer, readDocumentFile, sameFile } from "./document.js";
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 { findCommit, journalPathFor, recoverStages } from "./journal.js";
4
- import { acquireLease } from "./lock.js";
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
- * settle any crashed stage, replay a committed `txId`, apply the change to a
20
- * fresh reviewer, save it (selective unless a full repack is allowed), and
21
- * commit the bytes atomically with a journal line and, in place, a backup.
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 (tool, call, destination) => {
300
- const source = await identityOfInput(call.path);
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 (tool.type === "compare") return Result.err(cliError({
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
- const revisedPath = call.args["revisedPath"];
320
- if (tool.type === "compare" && typeof revisedPath === "string") {
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}.`, tool.type === "compare" ? "Write the redline to a new file." : "Use --in-place to change the file itself; it keeps a backup."));
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(tool, call, options.destination);
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 lease = await acquireLease({
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 (lease.isErr()) return Result.err(lease.error);
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 = destinationPath === sourcePath && recovered.value.some(({ action }) => action === "rolledForward") ? source.value.identity : sourceIdentity;
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 (destinationPath === sourcePath && 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."));
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
- if (version.isErr()) return Result.err(recovered.value.length === 0 ? version.error : cliError({
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.value.verify,
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.value.release();
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: "folio-cli",
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() + TRANSACTION_LEASE_MS).toISOString()
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 rm(lockPath, { force: true });
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 };
@@ -1,5 +1,6 @@
1
1
  import { FolioCliError } from "./errors.js";
2
- import { FileIdentity } from "./document.js";
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 = {