(paths: DecisionPaths)
| 272 | * caller re-runs. Atomic rewrite (tmp + rename); refreshes the snapshot. |
| 273 | */ |
| 274 | export function compact(paths: DecisionPaths): CompactResult { |
| 275 | const lockPath = `${paths.log}.compact.lock`; |
| 276 | let lockFd: number; |
| 277 | try { |
| 278 | lockFd = openSync(lockPath, "wx"); // O_EXCL|O_CREAT — throws EEXIST if a compact holds it |
| 279 | } catch (err) { |
| 280 | if ((err as NodeJS.ErrnoException).code === "EEXIST") { |
| 281 | return { activeCount: computeActive(readEvents(paths)).length, archivedCount: 0, expungedCount: 0, skipped: true }; |
| 282 | } |
| 283 | throw err; |
| 284 | } |
| 285 | try { |
| 286 | const sizeBefore = existsSync(paths.log) ? statSync(paths.log).size : 0; |
| 287 | const events = readEvents(paths); |
| 288 | const active = computeActive(events); |
| 289 | const activeIds = new Set(active.map((d) => d.id)); |
| 290 | const redactedIds = new Set( |
| 291 | events.filter((e) => e.kind === "redact" && e.supersedes).map((e) => e.supersedes as string), |
| 292 | ); |
| 293 | // Superseded = a decide that's neither active nor redacted. Archive these for history. |
| 294 | const superseded = events.filter( |
| 295 | (e): e is DecisionEvent => e.kind === "decide" && !activeIds.has(e.id) && !redactedIds.has(e.id), |
| 296 | ); |
| 297 | |
| 298 | // Append-race guard: if the log grew/changed since we read it, an append landed — |
| 299 | // rewriting now would drop it. Abort untouched; the caller re-runs. |
| 300 | const sizeNow = existsSync(paths.log) ? statSync(paths.log).size : 0; |
| 301 | if (sizeNow !== sizeBefore) { |
| 302 | return { activeCount: active.length, archivedCount: 0, expungedCount: 0, skipped: true }; |
| 303 | } |
| 304 | |
| 305 | // One batched append (not one open/write/close per event) — matches the atomic |
| 306 | // batched rewrite of the active log below and shrinks the mid-compact crash window. |
| 307 | if (superseded.length) { |
| 308 | appendFileSync(paths.archive, superseded.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf-8"); |
| 309 | } |
| 310 | |
| 311 | const tmp = `${paths.log}.tmp.${process.pid}`; |
| 312 | writeFileSync(tmp, active.map((d) => JSON.stringify(d)).join("\n") + (active.length ? "\n" : ""), "utf-8"); |
| 313 | renameSync(tmp, paths.log); |
| 314 | writeSnapshot(paths, active); |
| 315 | |
| 316 | return { activeCount: active.length, archivedCount: superseded.length, expungedCount: redactedIds.size }; |
| 317 | } finally { |
| 318 | closeSync(lockFd); |
| 319 | try { |
| 320 | unlinkSync(lockPath); |
| 321 | } catch { |
| 322 | // best-effort lock cleanup; a leftover lock only blocks the NEXT compact, which re-runs |
| 323 | } |
| 324 | } |
| 325 | } |
no test coverage detected