@procedures/fs 0.27.2 → 0.27.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The repair of the end of an append-only NDJSON file after a crash (roadmap O51).
3
+ *
4
+ * A crash during an append can leave a part of a line at the end of the file. The next append
5
+ * then puts its line after that part, and the file has a bad line in its middle.
6
+ * `healNdjsonTail()` repairs the end before the next append.
7
+ */
8
+
9
+ import { open, type FileHandle } from "node:fs/promises";
10
+
11
+ /** What `healNdjsonTail()` did: nothing, a cut of a partial last line, or a newline after a complete last record. */
12
+ export type NdjsonTailAction = "none" | "truncated" | "newline";
13
+
14
+ /** The result of `healNdjsonTail()`. */
15
+ export interface NdjsonTailResult {
16
+ /** The change to the file. */
17
+ readonly action: NdjsonTailAction;
18
+ /** The number of bytes that the function removed or added. It is 0 for `none`. */
19
+ readonly bytes: number;
20
+ /** The size of the file after the repair. It is 0 for a file that does not exist. */
21
+ readonly size: number;
22
+ }
23
+
24
+ /** The options of `healNdjsonTail()`. */
25
+ export interface HealNdjsonTailOptions {
26
+ /**
27
+ * The maximum length in bytes of the last line. The function reads no more than this number of
28
+ * bytes from the end. A longer last line is an error. The default is 16 MiB.
29
+ */
30
+ maxLineBytes?: number | undefined;
31
+ /** With `false`, the function does not flush a change to the disk. The default is `true`. */
32
+ fsync?: boolean | undefined;
33
+ }
34
+
35
+ const NEWLINE = 0x0a;
36
+ const NEWLINE_BYTES = Uint8Array.of(NEWLINE);
37
+ const CHUNK_BYTES = 64 * 1024;
38
+ const DEFAULT_MAX_LINE_BYTES = 16 * 1024 * 1024;
39
+ const BOM = [0xef, 0xbb, 0xbf] as const;
40
+
41
+ /**
42
+ * The bytes of the file from `position`, `length` bytes or fewer. A read of a local file gives all
43
+ * the bytes up to the end of the file.
44
+ */
45
+ async function readAt(handle: FileHandle, position: number, length: number): Promise<Buffer> {
46
+ const buffer = Buffer.alloc(length);
47
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
48
+ return buffer.subarray(0, bytesRead);
49
+ }
50
+
51
+ /**
52
+ * True when the bytes hold one complete JSON value. No part of an object or of an array is valid
53
+ * JSON, so the function cannot keep a part of such a record.
54
+ */
55
+ function isCompleteJson(bytes: Buffer, atStart: boolean): boolean {
56
+ const text = atStart && BOM.every((byte, index) => bytes[index] === byte) ? bytes.subarray(BOM.length) : bytes;
57
+ try {
58
+ JSON.parse(text.toString("utf8"));
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * This function repairs the end of an append-only NDJSON file after a crash. Then the file ends in a newline, or it is empty.
67
+ *
68
+ * - A missing file, an empty file and a file that ends in a newline get no change.
69
+ * - A last line that is not valid JSON is a part of a record. The function cuts it off with `ftruncate`.
70
+ * - A last line that is valid JSON is a complete record without its newline. The function adds the newline.
71
+ * - The function reads only the last line, from the end of the file. It does not read the whole file.
72
+ *
73
+ * The records must be objects or arrays. A part of a number or of a literal can be valid JSON too.
74
+ * The function does not lock the file. Start it before an append, in the same lock as the append.
75
+ */
76
+ export async function healNdjsonTail(path: string, options: HealNdjsonTailOptions = {}): Promise<NdjsonTailResult> {
77
+ const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
78
+ let handle: FileHandle;
79
+ try {
80
+ handle = await open(path, "r+");
81
+ } catch (error) {
82
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { action: "none", bytes: 0, size: 0 };
83
+ throw error;
84
+ }
85
+ try {
86
+ const { size } = await handle.stat();
87
+ if (size === 0) return { action: "none", bytes: 0, size };
88
+ const [last] = await readAt(handle, size - 1, 1);
89
+ if (last === NEWLINE) return { action: "none", bytes: 0, size };
90
+
91
+ // Back from the end, one chunk at a time, to the newline before the last line
92
+ const lowest = Math.max(0, size - maxLineBytes - 1);
93
+ const parts: Buffer[] = [];
94
+ let lineStart = -1;
95
+ for (let end = size; end > lowest && lineStart === -1; ) {
96
+ const start = Math.max(lowest, end - CHUNK_BYTES);
97
+ const chunk = await readAt(handle, start, end - start);
98
+ const at = chunk.lastIndexOf(NEWLINE);
99
+ if (at === -1) {
100
+ parts.unshift(chunk);
101
+ } else {
102
+ parts.unshift(chunk.subarray(at + 1));
103
+ lineStart = start + at + 1;
104
+ }
105
+ end = start;
106
+ }
107
+ if (lineStart === -1) {
108
+ if (lowest > 0) throw new RangeError(`The last line of ${path} is longer than ${maxLineBytes} bytes (maxLineBytes)`);
109
+ lineStart = 0;
110
+ }
111
+
112
+ const flush = options.fsync ?? true;
113
+ if (isCompleteJson(Buffer.concat(parts), lineStart === 0)) {
114
+ await handle.write(NEWLINE_BYTES, 0, 1, size);
115
+ if (flush) await handle.sync();
116
+ return { action: "newline", bytes: 1, size: size + 1 };
117
+ }
118
+ await handle.truncate(lineStart);
119
+ if (flush) await handle.sync();
120
+ return { action: "truncated", bytes: size - lineStart, size: lineStart };
121
+ } finally {
122
+ await handle.close();
123
+ }
124
+ }
package/src/index.ts CHANGED
@@ -17,3 +17,5 @@ export * from "./procedures/fs.js";
17
17
  export * from "./module.js";
18
18
  // The root that confines each path (roadmap R33)
19
19
  export { OUTSIDE_ROOT, isInside, isOutsideRoot } from "./root.js";
20
+ // The file helpers: atomic write, NDJSON tail heal, keyed lock, serialized edit (roadmap O51)
21
+ export * from "./files/index.js";