OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / store-gc-keep-releases.test.js

117 lines · javascript ref:
/**
 * TreeStore.gc({ keepReleases, releases }) — retention by RELEASE COUNT.
 *
 * Why: the age gate alone (14 days) never collected anything from an app that
 * publishes several times a day. On tabertron 2026-10-11 the store held 75
 * copies of hyper-tps's ~600 MB hypertps.pck (~44 GiB), every one younger than
 * 14 days. A release entry names its whole closure (root tree + each dep's
 * tree + each per-arch prebuild tree), so "keep the newest N releases per app"
 * is an exact set of tree hashes.
 */

import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import { TreeStore, releaseTreeHashes } from '../lib/store.js'

const DAY = 24 * 60 * 60 * 1000

function mkstore () {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-keep-releases-'))
  const store = new TreeStore({ root })
  store.init()
  return { root, store }
}

function addTree (store, hash, pulledAt = Date.now()) {
  fs.mkdirSync(path.join(store.dependenciesDir, hash))
  store._writeMeta(hash, { pulledAt, schema: TreeStore.BLOB_LAYER_SCHEMA })
}

const h = (c) => c.repeat(64)

function release (name, version, publishedAt, treeHash, deps) {
  return { name, version, publishedAt, treeHash, deps }
}

test('releaseTreeHashes — root + dep trees + per-arch prebuild trees', (t) => {
  const e = release('app', '1.0.0', '2026-10-01T00:00:00.000Z', h('1'), [
    { name: 'a', version: '1', tree: h('2') },
    { name: 'b', version: '1', tree: h('3'), prebuilds: { 'linux-x64': h('4'), 'win32-x64': h('5') } },
    { name: 'broken', version: '1', tree: null, error: 'unreadable' }
  ])
  t.alike([...releaseTreeHashes(e)].sort(), [h('1'), h('2'), h('3'), h('4'), h('5')])
})

test('gc keepReleases — superseded release trees go even when fresh; newest N and shared trees stay', (t) => {
  const { root, store } = mkstore()
  // app: v1 (oldest), v2, v3 (newest). Shared dep S is in all three.
  // v1-only: root R1, dep D1, prebuild P1. v2: R2. v3: R3, prebuild P3.
  for (const c of ['a', 'b', 'c', 'd', 'e', 'f', '9']) addTree(store, h(c))
  const releases = [
    release('app', '0.1.1', '2026-10-01T00:00:00.000Z', h('a'), [
      { name: 'shared', version: '1', tree: h('9') },
      { name: 'old', version: '1', tree: h('b'), prebuilds: { 'linux-x64': h('c') } }
    ]),
    release('app', '0.1.3', '2026-10-03T00:00:00.000Z', h('e'), [
      { name: 'shared', version: '1', tree: h('9'), prebuilds: { 'linux-x64': h('f') } }
    ]),
    release('app', '0.1.2', '2026-10-02T00:00:00.000Z', h('d'), [
      { name: 'shared', version: '1', tree: h('9') }
    ])
  ]

  const r = store.gc({ maxAgeDays: 14, keepReleases: 2, releases })
  t.alike(r.removed.sort(), [h('a'), h('b'), h('c')], 'only the oldest release\'s own trees are removed')
  for (const c of ['d', 'e', 'f', '9']) t.ok(store.hasTree(h(c)), 'kept ' + c)
  fs.rmSync(root, { recursive: true, force: true })
})

test('gc keepReleases — installed or pinned trees of a superseded release are kept', (t) => {
  const { root, store } = mkstore()
  for (const c of ['a', 'b', 'c', 'd']) addTree(store, h(c))
  const releases = [
    release('app', '1', '2026-10-01T00:00:00.000Z', h('a'), [{ name: 'x', version: '1', tree: h('b') }]),
    release('app', '2', '2026-10-02T00:00:00.000Z', h('c'), []),
    release('app', '3', '2026-10-03T00:00:00.000Z', h('d'), [])
  ]
  store.addRef(h('a'), 'app') // v1 is what is installed here
  store.pin(h('b'))           // an operator's rollback pin
  const r = store.gc({ maxAgeDays: 14, keepReleases: 2, releases })
  t.alike(r.removed, [], 'nothing removed')
  fs.rmSync(root, { recursive: true, force: true })
})

test('gc keepReleases — trees in no release keep the age gate', (t) => {
  const { root, store } = mkstore()
  addTree(store, h('a'))                       // fresh, in no release (a publish not yet registered)
  addTree(store, h('b'), Date.now() - 30 * DAY) // old, in no release
  const r = store.gc({ maxAgeDays: 14, keepReleases: 2, releases: [] })
  t.alike(r.removed, [h('b')], 'only the old unreleased tree goes')
  fs.rmSync(root, { recursive: true, force: true })
})

test('gc keepReleases — apps are counted separately', (t) => {
  const { root, store } = mkstore()
  for (const c of ['a', 'b', 'c', 'd']) addTree(store, h(c))
  const releases = [
    release('one', '1', '2026-10-01T00:00:00.000Z', h('a'), []),
    release('one', '2', '2026-10-02T00:00:00.000Z', h('b'), []),
    release('two', '1', '2026-09-01T00:00:00.000Z', h('c'), []),
    release('two', '2', '2026-09-02T00:00:00.000Z', h('d'), [])
  ]
  const r = store.gc({ maxAgeDays: 14, keepReleases: 2, releases })
  t.alike(r.removed, [], 'each app keeps its own two')
  fs.rmSync(root, { recursive: true, force: true })
})

test('gc without keepReleases — unchanged: fresh release trees are kept', (t) => {
  const { root, store } = mkstore()
  for (const c of ['a', 'b', 'c']) addTree(store, h(c))
  const releases = ['a', 'b', 'c'].map((c, i) => release('app', String(i), '2026-10-0' + (i + 1) + 'T00:00:00.000Z', h(c), []))
  const r = store.gc({ maxAgeDays: 14, releases })
  t.alike(r.removed, [], 'age gate alone keeps all three')
  fs.rmSync(root, { recursive: true, force: true })
})