pruneBuilds

tcmts.cli.web.pruneBuilds · #nppvwoyv · term · runs on the server

uses node:path

Delete old builds: per app, keep the build its name points to now plus the newest keep others. Tabs still on a deleted build reload themselves onto the current one at their next server call. What's leased (leaseBuilds) stays: a build being made, and its assets, and the build a tcm start serves. What goes is moved aside first, and what was taken hold of meanwhile is put back.

import type { Codebase } from "#codebase/tcmts/cli/codebase";
import { dim } from "#codebase/tcmts/cli/style";
import {
  appsDir, assetsDir, buildWorkDir, clearLeftovers, currentBuild, heldBuilds, listBuilds, ownId,
} from "#codebase/tcmts/cli/web";
import type { BuildManifest } from "#codebase/tcmts/cli/web";
import { shortHash } from "#codebase/tcmts/core/hash";
import fs from "node:fs";
import path from "node:path";

export function pruneBuilds(cb: Codebase, keep: number, quiet = false): BuildManifest[] {
  const work = buildWorkDir(cb);
  clearLeftovers(work);
  // (read first: what's taken hold of after this is looked at again before anything goes)
  const held = heldBuilds(work);
  const current = new Set<string>(held.builds);
  const byApp = new Map<string, BuildManifest[]>();
  for (const b of listBuilds(cb)) {
    byApp.set(b.app, [...(byApp.get(b.app) ?? []), b]);
    try {
      current.add(currentBuild(cb, b.app));
    } catch {
      // the app's name is gone: nothing of it is current
    }
  }
  const trash = path.join(work, `pruned.${ownId()}`);
  const moveAside = (from: string, to: string) => {
    fs.mkdirSync(path.dirname(to), { recursive: true });
    try {
      fs.renameSync(from, to);
      return true;
    } catch {
      return false;
    }
  };
  let removed: BuildManifest[] = [];
  for (const builds of byApp.values()) {
    const others = builds.filter((b) => !current.has(b.hash));
    for (const b of others.slice(keep))
      if (moveAside(path.join(appsDir(cb), b.hash), path.join(trash, "apps", b.hash)))
        removed.push(b);
  }
  // the shared assets only the removed builds had
  if (removed.length) {
    const stem = (f: string) => f.replace(/\.(br|gz)$/, "");
    const used = new Set([...listBuilds(cb).flatMap((b) => b.assets ?? []), ...held.assets]);
    const dir = assetsDir(cb);
    for (const f of fs.existsSync(dir) ? fs.readdirSync(dir) : [])
      if (!used.has(stem(f))) moveAside(path.join(dir, f), path.join(trash, "assets", f));
    // what was taken hold of meanwhile goes back: builds, then the assets of what's kept
    const now = heldBuilds(work);
    removed = removed.filter(
      (b) =>
        !now.builds.has(b.hash) ||
        !moveAside(path.join(trash, "apps", b.hash), path.join(appsDir(cb), b.hash)),
    );
    const kept = new Set([...listBuilds(cb).flatMap((b) => b.assets ?? []), ...now.assets]);
    const aside = path.join(trash, "assets");
    for (const f of fs.existsSync(aside) ? fs.readdirSync(aside) : [])
      if (kept.has(stem(f))) moveAside(path.join(aside, f), path.join(dir, f));
    fs.rmSync(trash, { recursive: true, force: true });
    // and the differences between chunks that aren't both kept
    const deltas = path.join(appsDir(cb), "deltas");
    for (const f of fs.existsSync(deltas) ? fs.readdirSync(deltas) : []) {
      const [from, to] = f.replace(/\.dcb(\.none)?$/, "").split("--");
      if (!kept.has(from!) || !kept.has(to!)) fs.rmSync(path.join(deltas, f), { force: true });
    }
  }
  if (!quiet)
    console.log(
      removed.length
        ? `removed ${removed.length} build(s): ${removed.map((b) => `${b.app} ${shortHash(b.hash)}`).join(", ")}`
        : dim("nothing to prune"),
    );
  return removed;
}

uses

used by