Parent directory

session-file.ts

8038 bytes
  1import { randomUUID } from "node:crypto";
  2import { chmod, lstat, open, readFile, rename, unlink } from "node:fs/promises";
  3import { basename, dirname, join, resolve } from "node:path";
  4import {
  5  toEntryId,
  6  treeEntryFromUnknown,
  7  type BranchCandidate,
  8  type EntryId,
  9  type TreeEntry,
 10} from "./tree-model.ts";
 11
 12declare const sessionFilePathBrand: unique symbol;
 13
 14export type SessionFilePath = string & { readonly [sessionFilePathBrand]: "SessionFilePath" };
 15
 16export interface FileIdentity {
 17  readonly dev: number;
 18  readonly ino: number;
 19  readonly mode: number;
 20  readonly mtimeMs: number;
 21  readonly size: number;
 22}
 23
 24export interface SessionFileSnapshot {
 25  readonly path: SessionFilePath;
 26  readonly content: string;
 27  readonly identity: FileIdentity;
 28}
 29
 30interface RawLine {
 31  readonly content: string;
 32  readonly record?: Record<string, unknown>;
 33}
 34
 35export function toSessionFilePath(path: string): SessionFilePath {
 36  return resolve(path) as SessionFilePath;
 37}
 38
 39export async function captureSessionFile(sessionFile: string): Promise<SessionFileSnapshot> {
 40  const path = toSessionFilePath(sessionFile);
 41
 42  for (let attempt = 0; attempt < 2; attempt += 1) {
 43    const before = await getFileIdentity(path);
 44    const content = await readFile(path, "utf8");
 45    const after = await getFileIdentity(path);
 46
 47    if (sameIdentity(before, after) && Buffer.byteLength(content) === after.size) {
 48      return { path, content, identity: after };
 49    }
 50  }
 51
 52  throw new Error("Session file changed while it was read");
 53}
 54
 55export async function assertSessionFileUnchanged(snapshot: SessionFileSnapshot): Promise<void> {
 56  const current = await captureSessionFile(snapshot.path);
 57  if (!sameIdentity(snapshot.identity, current.identity) || snapshot.content !== current.content) {
 58    throw new Error("Session file changed while the dialog was open");
 59  }
 60}
 61
 62export function getSessionTreeEntries(snapshot: SessionFileSnapshot): TreeEntry[] {
 63  const entries: TreeEntry[] = [];
 64  for (const rawLine of splitRawLines(snapshot.content)) {
 65    const entry = treeEntryFromUnknown(rawLine.record);
 66    if (entry) entries.push(entry);
 67  }
 68  return entries;
 69}
 70
 71export function buildPrunedSessionContent(
 72  snapshot: SessionFileSnapshot,
 73  candidates: readonly BranchCandidate[],
 74): string {
 75  const expectedEntries = collectPruneEntries(candidates);
 76  const rawLines = splitRawLines(snapshot.content);
 77  const linesByEntryId = new Map<EntryId, number[]>();
 78
 79  for (let index = 0; index < rawLines.length; index += 1) {
 80    const rawLine = rawLines[index];
 81    if (!rawLine?.record) continue;
 82
 83    const id = rawLine.record.id;
 84    if (typeof id !== "string" || id.length === 0) continue;
 85
 86    const entryId = toEntryId(id);
 87    if (!expectedEntries.has(entryId)) continue;
 88
 89    const indexes = linesByEntryId.get(entryId) ?? [];
 90    indexes.push(index);
 91    linesByEntryId.set(entryId, indexes);
 92  }
 93
 94  const lineIndexesToRemove = new Set<number>();
 95  for (const [id, entry] of expectedEntries) {
 96    const indexes = linesByEntryId.get(id) ?? [];
 97    if (indexes.length !== 1) {
 98      throw new Error(`Could not safely locate exactly one JSONL line for entry ${id}`);
 99    }
100
101    const lineIndex = indexes[0];
102    if (lineIndex === undefined) {
103      throw new Error(`Could not safely locate JSONL line for entry ${id}`);
104    }
105
106    const rawLine = rawLines[lineIndex];
107    if (!rawLine?.record || !hasSameEntryIdentity(entryIdentityFromRecord(rawLine.record), entry)) {
108      throw new Error(`JSONL line for entry ${id} does not match the loaded session`);
109    }
110
111    lineIndexesToRemove.add(lineIndex);
112  }
113
114  return rawLines
115    .filter((_line, index) => !lineIndexesToRemove.has(index))
116    .map((line) => line.content)
117    .join("");
118}
119
120export function countPruneEntries(candidates: readonly BranchCandidate[]): number {
121  return collectPruneEntries(candidates).size;
122}
123
124export async function pruneSessionFile(
125  snapshot: SessionFileSnapshot,
126  candidates: readonly BranchCandidate[],
127): Promise<void> {
128  const prunedContent = buildPrunedSessionContent(snapshot, candidates);
129  await replaceSessionFile(snapshot, prunedContent);
130}
131
132async function replaceSessionFile(snapshot: SessionFileSnapshot, content: string): Promise<void> {
133  const temporaryPath = temporaryPathFor(snapshot.path);
134
135  try {
136    await writeFileAndSync(temporaryPath, content, snapshot.identity.mode);
137    await assertSessionFileUnchanged(snapshot);
138    await rename(temporaryPath, snapshot.path);
139  } catch (error) {
140    await removeIfPresent(temporaryPath);
141    throw error;
142  }
143}
144
145async function writeFileAndSync(path: string, content: string, mode: number): Promise<void> {
146  const file = await open(path, "wx", mode);
147  try {
148    await file.writeFile(content, "utf8");
149    await file.sync();
150  } finally {
151    await file.close();
152  }
153
154  await chmod(path, mode);
155}
156
157async function removeIfPresent(path: string): Promise<void> {
158  try {
159    await unlink(path);
160  } catch (error) {
161    if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
162  }
163}
164
165async function getFileIdentity(path: string): Promise<FileIdentity> {
166  const stats = await lstat(path);
167  if (!stats.isFile() || stats.isSymbolicLink()) {
168    throw new Error("Session file must be a regular file");
169  }
170
171  return {
172    dev: stats.dev,
173    ino: stats.ino,
174    mode: stats.mode,
175    mtimeMs: stats.mtimeMs,
176    size: stats.size,
177  };
178}
179
180function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
181  return (
182    left.dev === right.dev &&
183    left.ino === right.ino &&
184    left.mode === right.mode &&
185    left.mtimeMs === right.mtimeMs &&
186    left.size === right.size
187  );
188}
189
190function splitRawLines(content: string): RawLine[] {
191  const lines: RawLine[] = [];
192  let start = 0;
193
194  while (start < content.length) {
195    const lineEnd = content.indexOf("\n", start);
196    const end = lineEnd === -1 ? content.length : lineEnd + 1;
197    const line = content.slice(start, end);
198    const record = parseRecord(line);
199    lines.push(record ? { content: line, record } : { content: line });
200    start = end;
201  }
202
203  return lines;
204}
205
206function parseRecord(line: string): Record<string, unknown> | undefined {
207  const source = line.endsWith("\n") ? line.slice(0, -1) : line;
208  if (source.trim().length === 0) return undefined;
209
210  try {
211    const parsed: unknown = JSON.parse(source);
212    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
213    return parsed as Record<string, unknown>;
214  } catch {
215    return undefined;
216  }
217}
218
219function collectPruneEntries(candidates: readonly BranchCandidate[]): Map<EntryId, TreeEntry> {
220  const entriesById = new Map<EntryId, TreeEntry>();
221
222  for (const candidate of candidates) {
223    for (const entry of candidate.entries) {
224      const existingEntry = entriesById.get(entry.id);
225      if (existingEntry && !hasSameEntryIdentity(existingEntry, entry)) {
226        throw new Error(`Selected branches disagree about entry ${entry.id}`);
227      }
228      entriesById.set(entry.id, entry);
229    }
230  }
231
232  if (entriesById.size === 0) throw new Error("Select at least one branch");
233  return entriesById;
234}
235
236interface EntryIdentity {
237  readonly id: EntryId;
238  readonly parentId: EntryId | null;
239  readonly type: string;
240}
241
242function entryIdentityFromRecord(record: Record<string, unknown>): EntryIdentity | undefined {
243  const { id, parentId, type } = record;
244  if (
245    typeof id !== "string" ||
246    id.length === 0 ||
247    (typeof parentId !== "string" && parentId !== null) ||
248    typeof type !== "string" ||
249    type.length === 0
250  ) {
251    return undefined;
252  }
253
254  return {
255    id: id as EntryId,
256    parentId: parentId as EntryId | null,
257    type,
258  };
259}
260
261function hasSameEntryIdentity(
262  left: EntryIdentity | undefined,
263  right: EntryIdentity,
264): boolean {
265  if (!left) return false;
266  return left.id === right.id && left.parentId === right.parentId && left.type === right.type;
267}
268
269function temporaryPathFor(path: string): string {
270  return join(dirname(path), `.${basename(path)}.tree-prune-tmp-${process.pid}-${randomUUID()}`);
271}