@ccmsg/cli 0.13.0 → 0.14.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,16 +1,14 @@
1
1
  import {
2
- closeSync,
3
- lstatSync,
4
- mkdirSync,
5
- openSync,
6
- readdirSync,
7
- readFileSync,
8
- readSync,
9
- renameSync,
10
- statSync,
11
- unlinkSync,
12
- writeFileSync,
13
- } from "node:fs";
2
+ lstat,
3
+ mkdir,
4
+ open,
5
+ readdir,
6
+ readFile,
7
+ rename,
8
+ stat,
9
+ unlink,
10
+ writeFile,
11
+ } from "node:fs/promises";
14
12
  import type { Dirent } from "node:fs";
15
13
  import { basename, dirname, join, relative } from "node:path";
16
14
  import type {
@@ -62,27 +60,34 @@ const FIND_VISITS = 20_000;
62
60
  * caller — the role dispatch states for a `scope: "role"` op, and the session
63
61
  * the connection speaks for — is handed over unread. */
64
62
  export function fileHandlers(paths: Containment) {
65
- const viewer = (input: HandlerInput): Viewer => ({ role: input.role, sid: input.identity?.sid });
63
+ const viewer = (input: HandlerInput): Viewer => ({
64
+ role: input.role,
65
+ sid: input.identity?.sid,
66
+ });
66
67
 
67
68
  return {
68
- "dir.list": (input: HandlerInput): DirListResult => {
69
+ "dir.list": async (input: HandlerInput): Promise<DirListResult> => {
69
70
  const args = input.args as unknown as DirListArgs;
70
- const at = paths.root(args, viewer(input));
71
- const stat = existing(at);
71
+ const at = await paths.root(args, viewer(input));
72
+ const stat = await existing(at);
72
73
  if (!stat.isDirectory()) throw new OpError("not_found", `${args.path ?? ""} is not a folder`);
73
- return { sid: args.sid, path: at.path, entries: entriesOf(at.real) };
74
+ return {
75
+ sid: args.sid,
76
+ path: at.path,
77
+ entries: await entriesOf(at.real),
78
+ };
74
79
  },
75
80
 
76
- "file.read": (input: HandlerInput): FileReadResult => {
81
+ "file.read": async (input: HandlerInput): Promise<FileReadResult> => {
77
82
  const args = input.args as unknown as FileReadArgs;
78
- const at = paths.locate(args, viewer(input));
79
- const stat = existing(at);
83
+ const at = await paths.locate(args, viewer(input));
84
+ const stat = await existing(at);
80
85
  if (!stat.isFile()) throw new OpError("not_found", `${args.path} is not a file`);
81
86
  // As much as the answer may carry and no more: a file larger than the
82
87
  // limit is answered from its head, so reading it whole would cost the
83
88
  // instance the whole of a file whose size is what the limit exists to
84
89
  // refuse.
85
- const head = bytesOf(at.real, READ_LIMIT);
90
+ const head = await bytesOf(at.real, READ_LIMIT);
86
91
  const binary = isBinary(head);
87
92
  return {
88
93
  sid: args.sid,
@@ -95,115 +100,126 @@ export function fileHandlers(paths: Containment) {
95
100
  };
96
101
  },
97
102
 
98
- "file.write": (input: HandlerInput): FileWriteResult => {
103
+ "file.write": async (input: HandlerInput): Promise<FileWriteResult> => {
99
104
  const args = input.args as unknown as FileWriteArgs;
100
- const at = paths.inbox(args.sid, args.path, viewer(input));
105
+ const at = await paths.inbox(args.sid, args.path, viewer(input));
101
106
  // The inbox takes new notes, so an existing name is refused rather than
102
107
  // replaced; the folder itself is made, since a repository that has never
103
108
  // had one is exactly where the first note goes (DR-0019 §2.1).
104
- mkdirSync(dirname(at.real), { recursive: true });
105
- create(at.real, args.content);
109
+ await mkdir(dirname(at.real), { recursive: true });
110
+ await create(at.real, args.content);
106
111
  return { sid: args.sid, path: at.path };
107
112
  },
108
113
 
109
- "file.create": (input: HandlerInput): FileCreateResult => {
114
+ "file.create": async (input: HandlerInput): Promise<FileCreateResult> => {
110
115
  const args = input.args as unknown as FileCreateArgs;
111
- const at = paths.locate(args, viewer(input));
116
+ const at = await paths.locate(args, viewer(input));
112
117
  const parent = dirname(at.real);
113
- if (!isDirectory(parent)) {
118
+ if (!(await isDirectory(parent))) {
114
119
  throw new OpError("not_found", `${args.path} has no folder to be created in`);
115
120
  }
116
- create(at.real, args.content);
121
+ await create(at.real, args.content);
117
122
  return { sid: args.sid, path: at.path };
118
123
  },
119
124
 
120
- "file.edit": (input: HandlerInput): FileEditResult => {
125
+ "file.edit": async (input: HandlerInput): Promise<FileEditResult> => {
121
126
  const args = input.args as unknown as FileEditArgs;
122
- const at = paths.locate(args, viewer(input));
123
- const stat = existing(at);
124
- if (!stat.isFile()) throw new OpError("not_found", `${args.path} is not a file`);
125
- if (isBinary(bytesOf(at.real, SNIFF))) {
127
+ const at = await paths.locate(args, viewer(input));
128
+ const before = await existing(at);
129
+ if (!before.isFile()) throw new OpError("not_found", `${args.path} is not a file`);
130
+ if (isBinary(await bytesOf(at.real, SNIFF))) {
126
131
  throw new OpError("not_a_text_file", `${args.path} holds binary content`);
127
132
  }
128
- if (mtimeOf(stat) !== args.expected_mtime_at || stat.size !== args.expected_size) {
133
+ if (mtimeOf(before) !== args.expected_mtime_at || before.size !== args.expected_size) {
129
134
  throw new OpError("file_conflict", `${args.path} changed since it was read`);
130
135
  }
131
- replace(at.real, args.content);
132
- const after = statSync(at.real);
133
- return { sid: args.sid, path: at.path, size: after.size, mtime_at: mtimeOf(after) };
136
+ await replace(at.real, args.content);
137
+ const after = await stat(at.real);
138
+ return {
139
+ sid: args.sid,
140
+ path: at.path,
141
+ size: after.size,
142
+ mtime_at: mtimeOf(after),
143
+ };
134
144
  },
135
145
 
136
- "file.delete": (input: HandlerInput): FileDeleteResult => {
146
+ "file.delete": async (input: HandlerInput): Promise<FileDeleteResult> => {
137
147
  const args = input.args as unknown as FileDeleteArgs;
138
- const at = paths.locate(args, viewer(input));
148
+ const at = await paths.locate(args, viewer(input));
139
149
  // What is unlinked is what is named, so this reads the name itself rather
140
150
  // than what it resolves to: a symlink is refused as the wrong kind of
141
151
  // thing instead of taking its target's answer. The resolved path is the
142
152
  // one containment admitted and would answer for the target, which is the
143
153
  // file a link inside the root could otherwise be pointed at.
144
- const stat = lstatOf(at.named);
154
+ const stat = await lstatOf(at.named);
145
155
  if (stat === undefined) throw new OpError("not_found", `${args.path} is not there`);
146
156
  if (!stat.isFile()) {
147
157
  throw new OpError("path_forbidden", `${args.path} is not a plain file`);
148
158
  }
149
- unlinkSync(at.named);
159
+ await unlink(at.named);
150
160
  return { sid: args.sid, path: at.path };
151
161
  },
152
162
 
153
- "file.find": (input: HandlerInput): FileFindResult => {
163
+ "file.find": async (input: HandlerInput): Promise<FileFindResult> => {
154
164
  const args = input.args as unknown as FileFindArgs;
155
- const at = paths.root(
156
- { sid: args.sid, kind: args.kind, ...(args.root === undefined ? {} : { path: args.root }) },
165
+ const at = await paths.root(
166
+ {
167
+ sid: args.sid,
168
+ kind: args.kind,
169
+ ...(args.root === undefined ? {} : { path: args.root }),
170
+ },
157
171
  viewer(input),
158
172
  );
159
173
  const terms = parseQuery(args.query);
160
174
  // A query with nothing to include matches nothing rather than the whole
161
175
  // tree, so a cleared search box costs no walk at all.
162
176
  if (terms.include.length === 0) return { sid: args.sid, hits: [], truncated: false };
163
- const walk = find(at, terms, args.respect_gitignore ?? true);
177
+ const walk = await find(at, terms, args.respect_gitignore ?? true);
164
178
  return { sid: args.sid, hits: walk.hits, truncated: walk.truncated };
165
179
  },
166
180
 
167
- "file.stat": (input: HandlerInput): FileStatResult => {
181
+ "file.stat": async (input: HandlerInput): Promise<FileStatResult> => {
168
182
  const args = input.args as unknown as FileStatArgs;
169
- const results = args.paths.map((path): FileStatEntry | null => {
170
- const at = paths.identify(args.sid, path, viewer(input));
171
- if (at === undefined || !isFile(at.real)) return null;
172
- return { kind: at.kind, path: at.path };
173
- });
183
+ const results = await Promise.all(
184
+ args.paths.map(async (path): Promise<FileStatEntry | null> => {
185
+ const at = await paths.identify(args.sid, path, viewer(input));
186
+ if (at === undefined || !(await isFile(at.real))) return null;
187
+ return { kind: at.kind, path: at.path };
188
+ }),
189
+ );
174
190
  return { results };
175
191
  },
176
192
  };
177
193
  }
178
194
 
179
195
  /** The file a located path names, or the contract's word for "not there". */
180
- function existing(at: Located) {
196
+ async function existing(at: Located) {
181
197
  try {
182
- return statSync(at.real);
198
+ return await stat(at.real);
183
199
  } catch {
184
200
  throw new OpError("not_found", `${at.path} is not there`);
185
201
  }
186
202
  }
187
203
 
188
- function lstatOf(path: string) {
204
+ async function lstatOf(path: string) {
189
205
  try {
190
- return lstatSync(path);
206
+ return await lstat(path);
191
207
  } catch {
192
208
  return undefined;
193
209
  }
194
210
  }
195
211
 
196
- function isFile(path: string): boolean {
212
+ async function isFile(path: string): Promise<boolean> {
197
213
  try {
198
- return statSync(path).isFile();
214
+ return (await stat(path)).isFile();
199
215
  } catch {
200
216
  return false;
201
217
  }
202
218
  }
203
219
 
204
- function isDirectory(path: string): boolean {
220
+ async function isDirectory(path: string): Promise<boolean> {
205
221
  try {
206
- return statSync(path).isDirectory();
222
+ return (await stat(path)).isDirectory();
207
223
  } catch {
208
224
  return false;
209
225
  }
@@ -216,14 +232,14 @@ function mtimeOf(stat: { mtimeMs: number }): Timestamp {
216
232
  }
217
233
 
218
234
  /** A file's leading bytes, at most `limit` of them. */
219
- function bytesOf(path: string, limit: number): Buffer {
220
- const fd = openSync(path, "r");
235
+ async function bytesOf(path: string, limit: number): Promise<Buffer> {
236
+ const handle = await open(path, "r");
221
237
  try {
222
238
  const buffer = Buffer.alloc(limit);
223
- const read = readSync(fd, buffer, 0, limit, 0);
224
- return buffer.subarray(0, read);
239
+ const { bytesRead } = await handle.read(buffer, 0, limit, 0);
240
+ return buffer.subarray(0, bytesRead);
225
241
  } finally {
226
- closeSync(fd);
242
+ await handle.close();
227
243
  }
228
244
  }
229
245
 
@@ -233,9 +249,9 @@ function isBinary(bytes: Buffer): boolean {
233
249
 
234
250
  /** Write a file that must not be there yet. The exclusive open is what decides
235
251
  * it: a check followed by a write would answer about the moment before. */
236
- function create(path: string, content: string): void {
252
+ async function create(path: string, content: string): Promise<void> {
237
253
  try {
238
- writeFileSync(path, content, { flag: "wx" });
254
+ await writeFile(path, content, { flag: "wx" });
239
255
  } catch (cause) {
240
256
  if ((cause as NodeJS.ErrnoException).code === "EEXIST") {
241
257
  throw new OpError("file_exists", `${basename(path)} is already there`);
@@ -247,39 +263,42 @@ function create(path: string, content: string): void {
247
263
  /** Replace a file's content whole. The write lands beside it and is renamed
248
264
  * over it, so a reader sees either the old file or the new one and never a
249
265
  * half-written one. */
250
- function replace(path: string, content: string): void {
266
+ async function replace(path: string, content: string): Promise<void> {
251
267
  const temporary = `${path}.ccmsg-${process.pid}-${Date.now()}`;
252
- writeFileSync(temporary, content);
268
+ await writeFile(temporary, content);
253
269
  try {
254
- renameSync(temporary, path);
270
+ await rename(temporary, path);
255
271
  } catch (cause) {
256
- unlinkSync(temporary);
272
+ await unlink(temporary);
257
273
  throw cause;
258
274
  }
259
275
  }
260
276
 
261
- function entriesOf(dir: string): DirEntry[] {
262
- return readdirSync(dir, { withFileTypes: true })
263
- .map((entry): DirEntry => {
264
- const type = entry.isSymbolicLink()
265
- ? "symlink"
266
- : entry.isDirectory()
267
- ? "dir"
268
- : entry.isFile()
269
- ? "file"
270
- : "other";
271
- // A symlink is reported as itself, so what is stated about it is the link
272
- // and never what it points at — including one pointing out of the root,
273
- // which is listed here and refuses to resolve everywhere else.
274
- const stat = type === "symlink" ? undefined : lstatOf(join(dir, entry.name));
275
- return {
276
- name: entry.name,
277
- type,
278
- ...(stat?.isFile() === true ? { size: stat.size } : {}),
279
- ...(stat === undefined ? {} : { mtime_at: mtimeOf(stat) }),
280
- };
281
- })
282
- .sort((a, b) => a.name.localeCompare(b.name));
277
+ async function entriesOf(dir: string): Promise<DirEntry[]> {
278
+ const entries = await readdir(dir, { withFileTypes: true });
279
+ return (
280
+ await Promise.all(
281
+ entries.map(async (entry): Promise<DirEntry> => {
282
+ const type = entry.isSymbolicLink()
283
+ ? "symlink"
284
+ : entry.isDirectory()
285
+ ? "dir"
286
+ : entry.isFile()
287
+ ? "file"
288
+ : "other";
289
+ // A symlink is reported as itself, so what is stated about it is the link
290
+ // and never what it points at including one pointing out of the root,
291
+ // which is listed here and refuses to resolve everywhere else.
292
+ const info = type === "symlink" ? undefined : await lstatOf(join(dir, entry.name));
293
+ return {
294
+ name: entry.name,
295
+ type,
296
+ ...(info?.isFile() === true ? { size: info.size } : {}),
297
+ ...(info === undefined ? {} : { mtime_at: mtimeOf(info) }),
298
+ };
299
+ }),
300
+ )
301
+ ).sort((a, b) => a.name.localeCompare(b.name));
283
302
  }
284
303
 
285
304
  interface Terms {
@@ -312,17 +331,17 @@ function matches(path: string, terms: Terms): boolean {
312
331
  * Both caps are reported the same way: the hits are the ones found, and
313
332
  * `truncated` says they are not the whole match set. Saying so is better than
314
333
  * implying these are all. */
315
- function find(at: Located, terms: Terms, respectGitignore: boolean) {
334
+ async function find(at: Located, terms: Terms, respectGitignore: boolean) {
316
335
  const hits: FileFindHit[] = [];
317
336
  let visits = 0;
318
337
  let truncated = false;
319
338
 
320
- const walk = (dir: string, ignored: Ignores): void => {
339
+ const walk = async (dir: string, ignored: Ignores): Promise<void> => {
321
340
  if (truncated) return;
322
- const here = respectGitignore ? ignored.descend(dir) : ignored;
341
+ const here = respectGitignore ? await ignored.descend(dir) : ignored;
323
342
  let entries: Dirent[];
324
343
  try {
325
- entries = readdirSync(dir, { withFileTypes: true });
344
+ entries = await readdir(dir, { withFileTypes: true });
326
345
  } catch {
327
346
  // A folder that cannot be read contributes nothing, and a walk that
328
347
  // stopped at one would answer less than it can.
@@ -349,13 +368,13 @@ function find(at: Located, terms: Terms, respectGitignore: boolean) {
349
368
  // Only real directories are descended: a symlink is answered as itself,
350
369
  // and following one would walk out of the root the walk is bounded by.
351
370
  if (isDir && !entry.isSymbolicLink()) {
352
- walk(full, here);
371
+ await walk(full, here);
353
372
  if (truncated) return;
354
373
  }
355
374
  }
356
375
  };
357
376
 
358
- walk(at.real, EMPTY_IGNORES);
377
+ await walk(at.real, EMPTY_IGNORES);
359
378
  return { hits, truncated };
360
379
  }
361
380
 
@@ -372,7 +391,7 @@ function find(at: Located, terms: Terms, respectGitignore: boolean) {
372
391
  * hiding one. */
373
392
  interface Ignores {
374
393
  hides(name: string, isDir: boolean): boolean;
375
- descend(dir: string): Ignores;
394
+ descend(dir: string): Promise<Ignores>;
376
395
  }
377
396
 
378
397
  const ALWAYS_HIDDEN = new Set([".git"]);
@@ -385,17 +404,17 @@ function makeIgnores(patterns: readonly RegExp[]): Ignores {
385
404
  if (ALWAYS_HIDDEN.has(name)) return true;
386
405
  return patterns.some((pattern) => pattern.test(name));
387
406
  },
388
- descend(dir) {
389
- const own = readIgnoreFile(join(dir, ".gitignore"));
407
+ async descend(dir) {
408
+ const own = await readIgnoreFile(join(dir, ".gitignore"));
390
409
  return own.length === 0 ? makeIgnores(patterns) : makeIgnores([...patterns, ...own]);
391
410
  },
392
411
  };
393
412
  }
394
413
 
395
- function readIgnoreFile(file: string): RegExp[] {
414
+ async function readIgnoreFile(file: string): Promise<RegExp[]> {
396
415
  let text: string;
397
416
  try {
398
- text = readFileSync(file, "utf8");
417
+ text = await readFile(file, "utf8");
399
418
  } catch {
400
419
  return [];
401
420
  }
Binary file
@@ -363,6 +363,11 @@ export class Instance {
363
363
  readonly #direct: DirectRoute;
364
364
  readonly #notify: Notify;
365
365
  readonly #translate: Translate | undefined;
366
+ /** Everything that writes to the state directory as it changes rather than
367
+ * at exit (DESIGN §2.5). A stop waits for what they have asked for before it
368
+ * lets go of the config home, so a successor reads what this instance last
369
+ * said (DESIGN §8.5 step 4). */
370
+ readonly #persisted: { flush(): Promise<void> }[] = [];
366
371
  /** The person's authentication: who may open a connection, and the records
367
372
  * that say so (DR-0001). */
368
373
  readonly #auth: Auth;
@@ -438,7 +443,19 @@ export class Instance {
438
443
  // what this instance writes travels as its own (DR-0001 §2.6).
439
444
  element: (_topic, _instance, data) => {
440
445
  const stated = (data as { records?: AuthRecord[] } | undefined)?.records;
441
- if (Array.isArray(stated)) this.#auth.merge(stated);
446
+ // Folded in as it arrives: the element callback is not what the peer
447
+ // waits on, and the set is written down before the removals in it are
448
+ // acted on (DR-0015). A set that could not be written is said here and
449
+ // nowhere else — there is no caller to answer, and the records come
450
+ // back from the peers that also hold them.
451
+ if (Array.isArray(stated)) {
452
+ void this.#auth.merge(stated).catch((cause: unknown) => {
453
+ this.log.write("auth records merge failed", {
454
+ instance: this.self,
455
+ cause: String(cause),
456
+ });
457
+ });
458
+ }
442
459
  },
443
460
  // The mesh view is this instance's own, so the topic that carries it is
444
461
  // restated when that view moves (DESIGN §7.5).
@@ -549,6 +566,7 @@ export class Instance {
549
566
 
550
567
  const inbox = new Inbox(inboxPath(paths.stateDir));
551
568
  inbox.load();
569
+ this.#persisted.push(inbox);
552
570
  // Route (a) is the harness's own way in (DESIGN §6.5): Claude Code's messaging
553
571
  // socket, Codex's thread queue. Which one an instance speaks follows the
554
572
  // config home it answers for (DESIGN §4.1), and the flag turns the route off for
@@ -602,6 +620,7 @@ export class Instance {
602
620
  const kv = new KvStore(join(paths.stateDir, KV_DIR), this.self, (topic, data) => {
603
621
  this.#topics.publish(topic, data);
604
622
  });
623
+ this.#persisted.push(kv);
605
624
  this.#topics.attach("kv", kv);
606
625
 
607
626
  // The credentials, tokens and removals the mesh shares (DR-0001 §2.6).
@@ -615,6 +634,7 @@ export class Instance {
615
634
  this.#topics.publish("auth.records", { records: written });
616
635
  },
617
636
  });
637
+ this.#persisted.push(records);
618
638
  this.#auth = new Auth({
619
639
  self: this.self,
620
640
  records,
@@ -714,11 +734,9 @@ export class Instance {
714
734
  handle: (frame, conn) => {
715
735
  const admin = adminRequestOf(frame);
716
736
  if (admin !== undefined) {
717
- return Promise.resolve(
718
- handleAdmin(
719
- { auth: this.#auth, ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }) },
720
- admin,
721
- ),
737
+ return handleAdmin(
738
+ { auth: this.#auth, ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }) },
739
+ admin,
722
740
  );
723
741
  }
724
742
  return this.handle(frame, conn);
@@ -997,10 +1015,16 @@ export class Instance {
997
1015
  // 3. tell the connections, while they can still be told
998
1016
  const restarting: RestartingEvent = { ev: "restarting", instance: this.self };
999
1017
  for (const conn of this.#conns) conn.send(restarting);
1000
- // 4. settle what is persisted. `last_live` and the inbox are written as
1001
- // they change rather than at exit, so there is nothing held back to flush;
1002
- // the log's writer is synchronous for the same reason (DESIGN §2.5).
1018
+ // 4. settle what is persisted. `last_live`, the inbox, the store and the
1019
+ // records are written as they change rather than at exit (DESIGN §2.5), so
1020
+ // what is held back is only what has been asked for and has not landed
1021
+ // and it has to land before the lock goes, since a successor reads these
1022
+ // files as it starts.
1003
1023
  this.log.write("stopping", { instance: this.self });
1024
+ await Promise.allSettled([
1025
+ this.#sessions.flush(),
1026
+ ...this.#persisted.map((held) => held.flush()),
1027
+ ]);
1004
1028
  // 5. let the resources go, the unix socket last. Closing takes the path
1005
1029
  // this process bound, and only that one: the stable address is a symlink
1006
1030
  // nothing here touches, because a successor may have already pointed it at
@@ -1016,8 +1040,11 @@ export class Instance {
1016
1040
  } finally {
1017
1041
  // The pid and lock are the observable proof that this process is still
1018
1042
  // leaving. Released only after every listener has finished closing, so a
1019
- // client cannot mistake an unreachable socket for a completed stop.
1043
+ // client cannot mistake an unreachable socket for a completed stop — and
1044
+ // after the log's own last lines, this one included, are on disk: what a
1045
+ // reader wants from the log of a stopped instance is how it ended.
1020
1046
  remove(this.paths.pidFile);
1047
+ await this.log.flush();
1021
1048
  this.lock.release();
1022
1049
  }
1023
1050
  }
@@ -1,15 +1,24 @@
1
- import { appendFileSync, mkdirSync } from "node:fs";
1
+ import { mkdirSync } from "node:fs";
2
+ import { appendFile } from "node:fs/promises";
2
3
  import { dirname } from "node:path";
3
4
 
4
- /** The instance's log: one writer, and every line on disk before the call
5
- * returns (DESIGN §2.5).
5
+ /** The instance's log: one writer, and the file in the order the calls were
6
+ * made (DESIGN §2.5).
6
7
  *
7
- * The reason to read a log is to find out why a process stopped, so the line
8
- * that matters most is the last one written before it did. A buffered writer
9
- * is the one that loses exactly that line, so this one appends synchronously
10
- * and holds nothing the cost is a write per line, on a file that takes a
11
- * line per lifecycle event rather than per request. */
8
+ * A line is written where anything at all is happening a lifecycle step, a
9
+ * mesh callback, a session appearing so the write cannot be the one thing
10
+ * that stops the instance while it lands (DR-0015). Each append is chained onto
11
+ * the one before it, which is what keeps the order the calls stated; what a
12
+ * kill can cost is the last line or two that had not reached the file yet, and
13
+ * for a log that is a cheaper loss than holding the process still for every
14
+ * line.
15
+ *
16
+ * `flush` is for the one moment that loss is avoidable: a stop that is being
17
+ * waited on can wait for its own last line. */
12
18
  export class Log {
19
+ /** The appends already asked for, as one chain. */
20
+ #written: Promise<void> = Promise.resolve();
21
+
13
22
  constructor(
14
23
  private readonly file: string,
15
24
  /** Mirrored to stderr so a foreground run shows what it is doing. */
@@ -21,10 +30,17 @@ export class Log {
21
30
  write(message: string, fields: Record<string, unknown> = {}): void {
22
31
  const line = JSON.stringify({ at: new Date().toISOString(), message, ...fields });
23
32
  if (this.echo) process.stderr.write(`${line}\n`);
24
- try {
25
- appendFileSync(this.file, `${line}\n`);
26
- } catch {
27
- // A log that cannot be written is not a reason to stop serving.
28
- }
33
+ this.#written = this.#written.then(async () => {
34
+ try {
35
+ await appendFile(this.file, `${line}\n`);
36
+ } catch {
37
+ // A log that cannot be written is not a reason to stop serving.
38
+ }
39
+ });
40
+ }
41
+
42
+ /** Settle once every line asked for so far is on disk. */
43
+ async flush(): Promise<void> {
44
+ await this.#written;
29
45
  }
30
46
  }