/**
* 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 })
})