@stll/folio-cli 0.0.1-placeholder.0 → 0.1.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.
Files changed (45) hide show
  1. package/README.md +298 -0
  2. package/dist/bin.d.ts +1 -0
  3. package/dist/bin.js +30 -0
  4. package/dist/cli.d.ts +25 -0
  5. package/dist/cli.js +440 -0
  6. package/dist/document.d.ts +43 -0
  7. package/dist/document.js +127 -0
  8. package/dist/errors.d.ts +67 -0
  9. package/dist/errors.js +92 -0
  10. package/dist/execute-read.d.ts +64 -0
  11. package/dist/execute-read.js +220 -0
  12. package/dist/execute-write.d.ts +36 -0
  13. package/dist/execute-write.js +449 -0
  14. package/dist/flags.d.ts +38 -0
  15. package/dist/flags.js +102 -0
  16. package/dist/journal.d.ts +82 -0
  17. package/dist/journal.js +212 -0
  18. package/dist/lock.d.ts +55 -0
  19. package/dist/lock.js +157 -0
  20. package/dist/mcp.d.ts +28 -0
  21. package/dist/mcp.js +310 -0
  22. package/dist/output-file.d.ts +14 -0
  23. package/dist/output-file.js +60 -0
  24. package/dist/output.d.ts +33 -0
  25. package/dist/output.js +77 -0
  26. package/dist/package-parts.d.ts +24 -0
  27. package/dist/package-parts.js +172 -0
  28. package/dist/package.js +4 -0
  29. package/dist/preview-commands.d.ts +8 -0
  30. package/dist/preview-commands.js +226 -0
  31. package/dist/provenance.d.ts +17 -0
  32. package/dist/provenance.js +53 -0
  33. package/dist/registry.d.ts +69 -0
  34. package/dist/registry.js +255 -0
  35. package/dist/render.d.ts +35 -0
  36. package/dist/render.js +120 -0
  37. package/dist/roots.d.ts +21 -0
  38. package/dist/roots.js +57 -0
  39. package/dist/serve.d.ts +17 -0
  40. package/dist/serve.js +234 -0
  41. package/dist/sidecar.d.ts +52 -0
  42. package/dist/sidecar.js +156 -0
  43. package/dist/transaction.d.ts +55 -0
  44. package/dist/transaction.js +218 -0
  45. package/package.json +85 -3
package/dist/serve.js ADDED
@@ -0,0 +1,234 @@
1
+ import { readDocumentFile } from "./document.js";
2
+ import { journalPathFor } from "./journal.js";
3
+ import { buildDisplayList, displayListHtml } from "./render.js";
4
+ import { SIDECAR_DIRECTORY, inspectPath, readSidecarFile } from "./sidecar.js";
5
+ import { Result } from "better-result";
6
+ import { randomBytes } from "node:crypto";
7
+ import { watch } from "node:fs";
8
+ import { stat } from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { escapeHtmlText } from "@stll/folio-core/display-list/html/renderDisplayListToHtml";
11
+ import { createServer } from "node:http";
12
+ //#region src/serve.ts
13
+ /**
14
+ * `folio serve`: a read-only live preview of one document on 127.0.0.1.
15
+ *
16
+ * The page is the DOM backend's rendering of the document's display list,
17
+ * the same pages `folio render` writes. The server watches the file (and the
18
+ * `.folio/journal.jsonl` a folio write appends to) and tells the page over
19
+ * server-sent events when the file's version changes, so an edit made by the
20
+ * CLI, the MCP server or any other program shows up without a reload.
21
+ *
22
+ * It never writes: not the document, not a lock, not the journal. It binds
23
+ * to 127.0.0.1 only, every URL carries a random token, requests whose `Host`
24
+ * is not the loopback address it bound are refused (so a web page cannot reach
25
+ * it through a rebound name), and only GET and HEAD are answered.
26
+ */
27
+ const PREVIEW_HOST = "127.0.0.1";
28
+ /** How often the file is re-checked when a watch event may have been missed. */
29
+ const POLL_INTERVAL_MS = 1e3;
30
+ /** The latest `toVersion` the journal committed for this document, if any. */
31
+ const journalToVersion = async (documentPath) => {
32
+ const journalPath = journalPathFor(documentPath, void 0);
33
+ const parent = await inspectPath(path.dirname(journalPath));
34
+ if (parent.isErr() || parent.value.type !== "directory") return null;
35
+ const bytes = await readSidecarFile(journalPath);
36
+ if (bytes.isErr()) return null;
37
+ const lines = new TextDecoder().decode(bytes.value).split("\n");
38
+ for (let index = lines.length - 1; index >= 0; index--) {
39
+ const parsed = Result.try(() => JSON.parse(lines[index] ?? ""));
40
+ if (!parsed.isOk() || typeof parsed.value !== "object" || parsed.value === null) continue;
41
+ const entry = { ...parsed.value };
42
+ if (entry["type"] === "commit" && entry["path"] === documentPath) return typeof entry["toVersion"] === "string" ? entry["toVersion"] : null;
43
+ }
44
+ return null;
45
+ };
46
+ const SHELL_STYLE = "html, body { margin: 0; height: 100%; background: #e8e8e8; } iframe { border: 0; width: 100%; height: 100%; display: block; }";
47
+ const shellHtml = (title, nonce) => [
48
+ "<!doctype html>",
49
+ `<html><head><meta charset="utf-8"><title>${escapeHtmlText(title)}</title>`,
50
+ `<style nonce="${nonce}">${SHELL_STYLE}</style></head><body>`,
51
+ "<iframe id=\"document\" sandbox src=\"document.html\" title=\"document\"></iframe>",
52
+ `<script nonce="${nonce}">`,
53
+ "const frame = document.getElementById('document');",
54
+ "let version = null;",
55
+ "const events = new EventSource('events');",
56
+ "events.onmessage = (event) => {",
57
+ " const next = JSON.parse(event.data).fileVersion;",
58
+ " if (version !== null && next !== version) frame.src = 'document.html?v=' + next;",
59
+ " version = next;",
60
+ "};",
61
+ "<\/script></body></html>"
62
+ ].join("\n");
63
+ const errorHtml = (message) => `<!doctype html><html><head><meta charset="utf-8"><title>folio</title></head><body><pre>${escapeHtmlText(message)}</pre></body></html>`;
64
+ const SECURITY_HEADERS = {
65
+ "Cache-Control": "no-store",
66
+ "Referrer-Policy": "no-referrer",
67
+ "X-Content-Type-Options": "nosniff",
68
+ "Cross-Origin-Resource-Policy": "same-origin"
69
+ };
70
+ /** The rendered document may contain inline styles and data URLs, and nothing that runs. */
71
+ const DOCUMENT_CSP = "default-src 'none'; img-src data:; font-src data:; style-src 'unsafe-inline'";
72
+ /** Serve the preview until {@link PreviewServer.close}. */
73
+ const startPreviewServer = async ({ documentPath, port }) => {
74
+ const initial = await readDocumentFile(documentPath);
75
+ if (initial.isErr()) return Result.err(new Error(initial.error.message));
76
+ const realPath = initial.value.path;
77
+ const title = path.basename(realPath);
78
+ const prefix = `/${randomBytes(24).toString("base64url")}/`;
79
+ let stamp = null;
80
+ let rendered = null;
81
+ let rendering = null;
82
+ const listeners = /* @__PURE__ */ new Set();
83
+ /** Render when the file changed since the last render; one render at a time. */
84
+ const current = async () => {
85
+ const info = await Result.tryPromise(() => stat(realPath));
86
+ const next = info.isOk() ? {
87
+ size: info.value.size,
88
+ modifiedMs: info.value.mtimeMs,
89
+ ino: info.value.ino
90
+ } : null;
91
+ if (rendered !== null && next !== null && stamp !== null && next.size === stamp.size && next.modifiedMs === stamp.modifiedMs && next.ino === stamp.ino && rendered !== null) return rendered;
92
+ rendering ??= (async () => {
93
+ const file = await readDocumentFile(realPath);
94
+ if (file.isErr()) return {
95
+ type: "error",
96
+ fileVersion: null,
97
+ message: file.error.message
98
+ };
99
+ if (rendered !== null && rendered.fileVersion === file.value.fileVersion) return rendered;
100
+ const list = await buildDisplayList(file.value);
101
+ if (list.isErr()) return {
102
+ type: "error",
103
+ fileVersion: file.value.fileVersion,
104
+ message: list.error.message
105
+ };
106
+ return {
107
+ type: "page",
108
+ fileVersion: file.value.fileVersion,
109
+ html: displayListHtml(list.value, title),
110
+ pageCount: list.value.pages.length
111
+ };
112
+ })();
113
+ const result = await rendering;
114
+ rendering = null;
115
+ const changed = rendered === null || rendered.fileVersion !== result.fileVersion;
116
+ rendered = result;
117
+ stamp = next;
118
+ if (changed) {
119
+ const message = `data: ${JSON.stringify({ fileVersion: result.fileVersion })}\n\n`;
120
+ for (const listener of listeners) listener.write(message);
121
+ }
122
+ return result;
123
+ };
124
+ const check = () => {
125
+ current();
126
+ };
127
+ let boundPort = port;
128
+ const allowedHosts = () => /* @__PURE__ */ new Set([`${PREVIEW_HOST}:${String(boundPort)}`, `localhost:${String(boundPort)}`]);
129
+ const respond = (response, status, body, headers = {}) => {
130
+ response.writeHead(status, {
131
+ ...SECURITY_HEADERS,
132
+ ...headers
133
+ });
134
+ response.end(body);
135
+ };
136
+ const handle = async (request, response) => {
137
+ if (!allowedHosts().has(request.headers.host ?? "")) {
138
+ respond(response, 403, "forbidden", { "Content-Type": "text/plain" });
139
+ return;
140
+ }
141
+ if (request.method !== "GET" && request.method !== "HEAD") {
142
+ respond(response, 405, "method not allowed", {
143
+ "Content-Type": "text/plain",
144
+ Allow: "GET, HEAD"
145
+ });
146
+ return;
147
+ }
148
+ const url = new URL(request.url ?? "/", `http://${PREVIEW_HOST}`);
149
+ if (!url.pathname.startsWith(prefix)) {
150
+ respond(response, 404, "not found", { "Content-Type": "text/plain" });
151
+ return;
152
+ }
153
+ const route = url.pathname.slice(prefix.length);
154
+ if (route === "") {
155
+ const nonce = randomBytes(16).toString("base64");
156
+ respond(response, 200, shellHtml(title, nonce), {
157
+ "Content-Type": "text/html; charset=utf-8",
158
+ "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; frame-src 'self'; connect-src 'self'`
159
+ });
160
+ return;
161
+ }
162
+ if (route === "document.html") {
163
+ const page = await current();
164
+ respond(response, 200, page.type === "page" ? page.html : errorHtml(page.message), {
165
+ "Content-Type": "text/html; charset=utf-8",
166
+ "Content-Security-Policy": DOCUMENT_CSP
167
+ });
168
+ return;
169
+ }
170
+ if (route === "version") {
171
+ const page = await current();
172
+ respond(response, 200, JSON.stringify({
173
+ path: realPath,
174
+ fileVersion: page.fileVersion,
175
+ journalToVersion: await journalToVersion(realPath),
176
+ ...page.type === "page" ? { pageCount: page.pageCount } : { error: page.message }
177
+ }), { "Content-Type": "application/json" });
178
+ return;
179
+ }
180
+ if (route === "events") {
181
+ response.writeHead(200, {
182
+ ...SECURITY_HEADERS,
183
+ "Content-Type": "text/event-stream",
184
+ Connection: "keep-alive"
185
+ });
186
+ listeners.add(response);
187
+ request.on("close", () => listeners.delete(response));
188
+ const page = await current();
189
+ response.write(`data: ${JSON.stringify({ fileVersion: page.fileVersion })}\n\n`);
190
+ return;
191
+ }
192
+ respond(response, 404, "not found", { "Content-Type": "text/plain" });
193
+ };
194
+ const server = createServer((request, response) => {
195
+ handle(request, response).catch((error) => {
196
+ respond(response, 500, error instanceof Error ? error.message : "error", { "Content-Type": "text/plain" });
197
+ });
198
+ });
199
+ if ((await Result.tryPromise(() => new Promise((resolve, reject) => {
200
+ server.once("error", reject);
201
+ server.listen(port, "127.0.0.1", () => resolve());
202
+ }))).isErr()) return Result.err(/* @__PURE__ */ new Error(`Cannot listen on ${PREVIEW_HOST}:${String(port)}.`));
203
+ const address = server.address();
204
+ boundPort = typeof address === "object" && address !== null ? address.port : port;
205
+ const watchers = [];
206
+ const directory = path.dirname(realPath);
207
+ const watched = /* @__PURE__ */ new Set([
208
+ path.basename(realPath),
209
+ SIDECAR_DIRECTORY,
210
+ "journal.jsonl"
211
+ ]);
212
+ for (const target of [directory, path.join(directory, SIDECAR_DIRECTORY)]) {
213
+ const watcher = Result.try(() => watch(target, (_event, name) => {
214
+ if (name === null || watched.has(String(name))) check();
215
+ }));
216
+ if (watcher.isOk()) watchers.push(watcher.value);
217
+ }
218
+ const poll = setInterval(check, POLL_INTERVAL_MS);
219
+ check();
220
+ return Result.ok({
221
+ url: `http://${PREVIEW_HOST}:${String(boundPort)}${prefix}`,
222
+ close: async () => {
223
+ clearInterval(poll);
224
+ for (const watcher of watchers) watcher.close();
225
+ for (const listener of listeners) listener.end();
226
+ await new Promise((resolve) => {
227
+ server.close(() => resolve());
228
+ server.closeAllConnections();
229
+ });
230
+ }
231
+ });
232
+ };
233
+ //#endregion
234
+ export { PREVIEW_HOST, startPreviewServer };
@@ -0,0 +1,52 @@
1
+ import { FolioCliError } from "./errors.js";
2
+ import { FileIdentity } from "./document.js";
3
+ import { Result } from "better-result";
4
+ import { FileHandle } from "node:fs/promises";
5
+ //#region src/sidecar.d.ts
6
+ declare const SIDECAR_DIRECTORY = ".folio";
7
+ declare const unsafePath: (message: string, hint?: string) => FolioCliError;
8
+ /** What a path names, without following a final symlink. */
9
+ type PathEntry = {
10
+ type: "missing";
11
+ } | {
12
+ type: "file";
13
+ identity: FileIdentity;
14
+ links: number;
15
+ modifiedMs: number;
16
+ } | {
17
+ type: "directory";
18
+ identity: FileIdentity;
19
+ } | {
20
+ type: "other";
21
+ };
22
+ declare const inspectPath: (filePath: string) => Promise<Result<PathEntry, FolioCliError>>;
23
+ /**
24
+ * `<document directory>/.folio/<segments...>`, each level checked or created
25
+ * private. The document's own directory is the caller's choice and is not
26
+ * inspected.
27
+ */
28
+ declare const sidecarDirectory: (documentPath: string, ...segments: readonly string[]) => Promise<Result<string, FolioCliError>>;
29
+ /** Check the directory a sidecar file lives in; create it only when it is `.folio` itself. */
30
+ declare const ensureSidecarParent: (filePath: string) => Promise<Result<void, FolioCliError>>;
31
+ type OpenedSidecar = {
32
+ handle: FileHandle;
33
+ identity: FileIdentity;
34
+ size: number;
35
+ };
36
+ /**
37
+ * Open a sidecar file without following a final symlink, and refuse it
38
+ * unless it is a regular file with exactly one link.
39
+ */
40
+ declare const openSidecarFile: (filePath: string, flags: number) => Promise<Result<OpenedSidecar, FolioCliError>>;
41
+ /** Read a whole sidecar file through {@link openSidecarFile}. */
42
+ declare const readSidecarFile: (filePath: string) => Promise<Result<Uint8Array<ArrayBuffer>, FolioCliError>>;
43
+ /** Create a new sidecar file (never an existing one), write it, and flush it to disk. */
44
+ declare const writeNewSidecarFile: (filePath: string, bytes: Uint8Array) => Promise<Result<FileIdentity, FolioCliError>>;
45
+ /**
46
+ * Flush a directory's entries (a create or rename) to disk. Platforms that
47
+ * cannot open a directory for syncing report nothing; every other failure
48
+ * is returned.
49
+ */
50
+ declare const syncDirectory: (directory: string) => Promise<Result<void, FolioCliError>>;
51
+ //#endregion
52
+ export { PathEntry, SIDECAR_DIRECTORY, ensureSidecarParent, inspectPath, openSidecarFile, readSidecarFile, sidecarDirectory, syncDirectory, unsafePath, writeNewSidecarFile };
@@ -0,0 +1,156 @@
1
+ import { NO_FOLLOW, errnoCode } from "./document.js";
2
+ import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
3
+ import { Result } from "better-result";
4
+ import { constants } from "node:fs";
5
+ import { lstat, mkdir, open } from "node:fs/promises";
6
+ import path from "node:path";
7
+ //#region src/sidecar.ts
8
+ /**
9
+ * The one boundary every file folio keeps beside a document goes through:
10
+ * the lock, the stage, the journal, and backups. None of them follows a
11
+ * symlink or writes through a hard link. The `.folio` directory and its
12
+ * subdirectories are created private (0700), and an existing one that is a
13
+ * symlink or not a directory is refused rather than followed.
14
+ */
15
+ const SIDECAR_DIRECTORY = ".folio";
16
+ const PRIVATE_DIRECTORY_MODE = 448;
17
+ const PRIVATE_FILE_MODE = 384;
18
+ const describe = (error) => error instanceof Error ? error.message : String(error);
19
+ const unsafePath = (message, hint) => cliError({
20
+ code: FOLIO_CLI_ERROR_CODES.unsafePath,
21
+ message,
22
+ hint
23
+ });
24
+ const fileSystemError = (message, error) => cliError({
25
+ code: FOLIO_CLI_ERROR_CODES.internal,
26
+ message: `${message}: ${describe(error)}`
27
+ });
28
+ const inspectPath = async (filePath) => {
29
+ const info = await Result.tryPromise(() => lstat(filePath));
30
+ if (info.isErr()) return errnoCode(info.error.cause) === "ENOENT" ? Result.ok({ type: "missing" }) : Result.err(fileSystemError(`Cannot inspect ${filePath}`, info.error.cause));
31
+ const stats = info.value;
32
+ const identity = {
33
+ dev: stats.dev,
34
+ ino: stats.ino
35
+ };
36
+ if (stats.isFile()) return Result.ok({
37
+ type: "file",
38
+ identity,
39
+ links: stats.nlink,
40
+ modifiedMs: stats.mtimeMs
41
+ });
42
+ if (stats.isDirectory()) return Result.ok({
43
+ type: "directory",
44
+ identity
45
+ });
46
+ return Result.ok({ type: "other" });
47
+ };
48
+ /**
49
+ * Create `directory` private when missing; refuse it when it exists as a
50
+ * symlink or anything but a directory. Its parent must already exist.
51
+ */
52
+ const ensurePrivateDirectory = async (directory) => {
53
+ for (let attempt = 0; attempt < 2; attempt++) {
54
+ const entry = await inspectPath(directory);
55
+ if (entry.isErr()) return Result.err(entry.error);
56
+ if (entry.value.type === "directory") return Result.ok();
57
+ if (entry.value.type !== "missing") return Result.err(unsafePath(`${directory} is not a plain directory; folio does not write through it.`, "Remove or rename it (it may be a symlink), then retry."));
58
+ const made = await Result.tryPromise(() => mkdir(directory, { mode: PRIVATE_DIRECTORY_MODE }));
59
+ if (made.isOk()) return Result.ok();
60
+ if (errnoCode(made.error.cause) !== "EEXIST") return Result.err(fileSystemError(`Cannot create ${directory}`, made.error.cause));
61
+ }
62
+ return Result.err(unsafePath(`${directory} changed while it was being created.`));
63
+ };
64
+ /**
65
+ * `<document directory>/.folio/<segments...>`, each level checked or created
66
+ * private. The document's own directory is the caller's choice and is not
67
+ * inspected.
68
+ */
69
+ const sidecarDirectory = async (documentPath, ...segments) => {
70
+ let directory = path.join(path.dirname(documentPath), SIDECAR_DIRECTORY);
71
+ const top = await ensurePrivateDirectory(directory);
72
+ if (top.isErr()) return Result.err(top.error);
73
+ for (const segment of segments) {
74
+ if (segment === "" || segment === "." || segment === ".." || segment.includes(path.sep)) return Result.err(unsafePath(`${JSON.stringify(segment)} is not a plain name.`));
75
+ directory = path.join(directory, segment);
76
+ const level = await ensurePrivateDirectory(directory);
77
+ if (level.isErr()) return Result.err(level.error);
78
+ }
79
+ return Result.ok(directory);
80
+ };
81
+ /** Check the directory a sidecar file lives in; create it only when it is `.folio` itself. */
82
+ const ensureSidecarParent = async (filePath) => {
83
+ const parent = path.dirname(filePath);
84
+ if (path.basename(parent) === ".folio") return await ensurePrivateDirectory(parent);
85
+ const entry = await inspectPath(parent);
86
+ if (entry.isErr()) return Result.err(entry.error);
87
+ return entry.value.type === "directory" ? Result.ok() : Result.err(unsafePath(`${parent} is not a plain directory.`));
88
+ };
89
+ /**
90
+ * Open a sidecar file without following a final symlink, and refuse it
91
+ * unless it is a regular file with exactly one link.
92
+ */
93
+ const openSidecarFile = async (filePath, flags) => {
94
+ const opened = await Result.tryPromise(() => open(filePath, flags | NO_FOLLOW, PRIVATE_FILE_MODE));
95
+ if (opened.isErr()) {
96
+ const code = errnoCode(opened.error.cause);
97
+ return code === "ELOOP" || code === "EMLINK" ? Result.err(unsafePath(`${filePath} is a symlink; folio does not follow it.`)) : Result.err(fileSystemError(`Cannot open ${filePath}`, opened.error.cause));
98
+ }
99
+ const handle = opened.value;
100
+ const info = await Result.tryPromise(() => handle.stat());
101
+ if (info.isErr() || !info.value.isFile() || info.value.nlink !== 1) {
102
+ await handle.close();
103
+ return Result.err(unsafePath(`${filePath} is not a regular file with a single link; folio does not use it.`));
104
+ }
105
+ return Result.ok({
106
+ handle,
107
+ identity: {
108
+ dev: info.value.dev,
109
+ ino: info.value.ino
110
+ },
111
+ size: info.value.size
112
+ });
113
+ };
114
+ /** Read a whole sidecar file through {@link openSidecarFile}. */
115
+ const readSidecarFile = async (filePath) => {
116
+ const opened = await openSidecarFile(filePath, constants.O_RDONLY);
117
+ if (opened.isErr()) return Result.err(opened.error);
118
+ const { handle } = opened.value;
119
+ const read = await Result.tryPromise(async () => new Uint8Array(await handle.readFile()));
120
+ await handle.close();
121
+ return read.isOk() ? Result.ok(read.value) : Result.err(fileSystemError(`Cannot read ${filePath}`, read.error.cause));
122
+ };
123
+ /** Create a new sidecar file (never an existing one), write it, and flush it to disk. */
124
+ const writeNewSidecarFile = async (filePath, bytes) => {
125
+ const opened = await openSidecarFile(filePath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
126
+ if (opened.isErr()) return Result.err(opened.error);
127
+ const { handle, identity } = opened.value;
128
+ const written = await Result.tryPromise(async () => {
129
+ try {
130
+ await handle.writeFile(bytes);
131
+ await handle.sync();
132
+ } finally {
133
+ await handle.close();
134
+ }
135
+ });
136
+ return written.isOk() ? Result.ok(identity) : Result.err(fileSystemError(`Cannot write ${filePath}`, written.error.cause));
137
+ };
138
+ /**
139
+ * Flush a directory's entries (a create or rename) to disk. Platforms that
140
+ * cannot open a directory for syncing report nothing; every other failure
141
+ * is returned.
142
+ */
143
+ const syncDirectory = async (directory) => {
144
+ if (process.platform === "win32") return Result.ok();
145
+ const synced = await Result.tryPromise(async () => {
146
+ const handle = await open(directory, constants.O_RDONLY);
147
+ try {
148
+ await handle.sync();
149
+ } finally {
150
+ await handle.close();
151
+ }
152
+ });
153
+ return synced.isOk() ? Result.ok() : Result.err(fileSystemError(`Cannot flush ${directory}`, synced.error.cause));
154
+ };
155
+ //#endregion
156
+ export { SIDECAR_DIRECTORY, ensureSidecarParent, inspectPath, openSidecarFile, readSidecarFile, sidecarDirectory, syncDirectory, unsafePath, writeNewSidecarFile };
@@ -0,0 +1,55 @@
1
+ import { FolioCliError } from "./errors.js";
2
+ import { FileIdentity } from "./document.js";
3
+ import { JournalCommit } from "./journal.js";
4
+ import { ChangedPart } from "./package-parts.js";
5
+ import { Result } from "better-result";
6
+ //#region src/transaction.d.ts
7
+ /** Backups kept per document. */
8
+ declare const MAX_BACKUPS = 20;
9
+ type WriteDestination = {
10
+ type: "inPlace";
11
+ } | {
12
+ type: "file";
13
+ path: string;
14
+ /** Allow replacing an existing file; its version must then be `expectedVersion`. */
15
+ overwrite: boolean;
16
+ expectedVersion: string | undefined;
17
+ };
18
+ /** `.folio/backups/<document name>`, created private and never through a symlink. */
19
+ declare const backupDirectoryFor: (documentPath: string) => Promise<Result<string, FolioCliError>>;
20
+ /**
21
+ * Keep the newest `keep` backups in one document's backup directory. Only
22
+ * regular files named `<version>.docx` count; nothing else is touched.
23
+ */
24
+ declare const pruneBackups: (directory: string, keep: number) => Promise<Result<void, FolioCliError>>;
25
+ type CommitRequest = {
26
+ txId: string;
27
+ destinationPath: string;
28
+ destination: WriteDestination;
29
+ /** The file the transaction read; re-read under the lease before anything is written. */
30
+ sourcePath: string;
31
+ sourceIdentity: FileIdentity;
32
+ fromVersion: string;
33
+ bytes: Uint8Array<ArrayBuffer>;
34
+ changedParts: readonly ChangedPart[];
35
+ journalPath: string;
36
+ /** Refuses unless the transaction still holds the destination's lease. */
37
+ verifyLease: () => Promise<Result<void, FolioCliError>>;
38
+ /** The journal line, completed with the stage, versions, and backup here. */
39
+ entry: Omit<JournalCommit, "type" | "path" | "toVersion" | "destinationVersionBefore" | "stage" | "receipt">;
40
+ /** The receipt returned to the caller; completed with `toVersion` and `backup`. */
41
+ receipt: Readonly<Record<string, unknown>>;
42
+ };
43
+ type CommitResult = {
44
+ toVersion: string;
45
+ backup: string | undefined;
46
+ receipt: Readonly<Record<string, unknown>>;
47
+ };
48
+ /**
49
+ * Commit under a lease the caller already holds. Refuses when the source
50
+ * changed since it was read, when a new destination already exists, or when
51
+ * an existing destination is not the version the caller named.
52
+ */
53
+ declare const commitTransaction: (request: CommitRequest) => Promise<Result<CommitResult, FolioCliError>>;
54
+ //#endregion
55
+ export { CommitRequest, CommitResult, MAX_BACKUPS, WriteDestination, backupDirectoryFor, commitTransaction, pruneBackups };