1/**
2 * Per-resource dev log files.
3 *
4 * Layout under `{dotAlchemy}/log`:
5 *
6 * log/{stage}/{fqn…}/{timestamp}.log — one file per instance start
7 * (a restart opens a new file;
8 * the FQN nests as directories,
9 * e.g. log/dev/Site/Worker/)
10 * log/{stage}/{timestamp}.log — the dev child's mixed tail
11 *
12 * Retention: opening a new log prunes its sibling `*.log` files first — the
13 * newest `LOG_RETENTION_GENERATIONS` survive, and anything older than
14 * `LOG_RETENTION_MS` (7 days, judged by stat mtime — file names are never
15 * trusted) is deleted regardless. Pruning is best-effort: any failure is
16 * swallowed so it can never break dev startup.
17 */
18import * as Clock from "effect/Clock";
19import * as Effect from "effect/Effect";
20import * as Duration from "effect/Duration";
21import * as FileSystem from "effect/FileSystem";
22import * as Option from "effect/Option";
23import * as Path from "effect/Path";
24import * as Queue from "effect/Queue";
25import * as Stream from "effect/Stream";
26import { AlchemyContext } from "../AlchemyContext.ts";
27import { stripVTControlCharacters } from "node:util";
28
29const LOG_RETENTION = Duration.days(7);
30const LOG_RETENTION_GENERATIONS = 10;
31
32/**
33 * Delete a resource's stale sibling logs before a new one is opened: keep
34 * the newest `LOG_RETENTION_GENERATIONS - 1` (the file about to be created
35 * fills the last slot) and drop anything older than `LOG_RETENTION_MS`.
36 * Ages come from stat mtime — file names are never trusted. Best-effort:
37 * every step (including a missing directory) is swallowed.
38 */
39const pruneDevLogs = (
40 fs: FileSystem.FileSystem,
41 path: Path.Path,
42 dir: string,
43): Effect.Effect<void> =>
44 Effect.gen(function* () {
45 const entries = yield* fs
46 .readDirectory(dir)
47 .pipe(Effect.orElseSucceed(() => [] as Array<string>));
48 const logs: Array<{ file: string; mtime: number }> = [];
49 for (const entry of entries) {
50 if (!entry.endsWith(".log")) continue;
51 const file = path.join(dir, entry);
52 const info = yield* fs
53 .stat(file)
54 .pipe(Effect.orElseSucceed(() => undefined));
55 const mtime =
56 info === undefined ? undefined : Option.getOrUndefined(info.mtime);
57 if (mtime !== undefined) logs.push({ file, mtime: mtime.getTime() });
58 }
59 const cutoff =
60 (yield* Clock.currentTimeMillis) - Duration.toMillis(LOG_RETENTION);
61 logs.sort((a, b) => b.mtime - a.mtime);
62 for (const [index, log] of logs.entries()) {
63 if (index >= LOG_RETENTION_GENERATIONS - 1 || log.mtime < cutoff) {
64 yield* fs.remove(log.file).pipe(Effect.ignore);
65 }
66 }
67 }).pipe(Effect.ignore);
68
69/**
70 * Resolves the directory a resource's dev logs land in, for surfacing in
71 * startup messages ("Started … → url (logs: …)") without opening a file.
72 * Must join the same segments the opener is later called with.
73 */
74export const makeDevLogDirectory = Effect.gen(function* () {
75 const path = yield* Path.Path;
76 const { dotAlchemy } = yield* AlchemyContext;
77 return (...segments: ReadonlyArray<string>) =>
78 path.join(dotAlchemy, "log", ...segments);
79});
80
81/**
82 * Resolves the file-system services once (at provider/process init) and
83 * returns an opener whose only requirement is the ambient `Scope` — local
84 * provider `start` signatures are Scope-only, so the opener composes into
85 * them without widening their requirements.
86 *
87 * The opener creates `{dotAlchemy}/log/{...segments}/{timestamp}.log`
88 * (pruning stale siblings per the module retention policy first) and
89 * returns the file's `path` plus synchronous `write`/`writeLine` sinks —
90 * chunks queue through a drain fiber, so they're safe to call from
91 * non-Effect callbacks (workerd output pumps, stream mirrors). The file and
92 * drain close with the scope, which ties one log file to one
93 * process/serve generation.
94 */
95export const makeDevLogOpener = Effect.gen(function* () {
96 const fs = yield* FileSystem.FileSystem;
97 const path = yield* Path.Path;
98 const devLogDirectory = yield* makeDevLogDirectory;
99 const encoder = new TextEncoder();
100
101 return Effect.fn(function* (...segments: ReadonlyArray<string>) {
102 const dir = devLogDirectory(...segments);
103 yield* fs.makeDirectory(dir, { recursive: true });
104 yield* pruneDevLogs(fs, path, dir);
105 const name = yield* Effect.sync(
106 () => `${new Date().toISOString().replaceAll(":", "-")}.log`,
107 );
108 const filePath = path.join(dir, name);
109 const file = yield* fs.open(filePath, { flag: "a" });
110 const queue = yield* Queue.make<Uint8Array>();
111 yield* Stream.fromQueue(queue).pipe(
112 Stream.runForEach((chunk) => file.write(chunk).pipe(Effect.ignore)),
113 Effect.forkScoped,
114 );
115 // ANSI is stripped: colors belong to the terminal path (FORCE_COLOR
116 // rides the sidecar pipes); the files stay plain-text grep-able.
117 return {
118 /** Absolute path of this generation's log file. */
119 path: filePath,
120 write: (chunk: string): void => {
121 Queue.offerUnsafe(
122 queue,
123 encoder.encode(stripVTControlCharacters(chunk)),
124 );
125 },
126 /** `write` + a trailing newline — for line-based sources. */
127 writeLine: (line: string): void => {
128 Queue.offerUnsafe(
129 queue,
130 encoder.encode(`${stripVTControlCharacters(line)}\n`),
131 );
132 },
133 };
134 });
135});