/**
* Retention-policy tests (2026-05-13).
*
* Covers:
* - PublishedIndex caps to DEFAULT_MAX_VERSIONS (3) on add()
* - Pinned versions are preserved past the cap
* - _sourcePath is stripped on store
* - TreeStore.setAppRefs is atomic per-app
* - TreeStore.clearAppRefs removes all refs for one app
* - TreeStore.gc honors maxAgeDays grace period
*/
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import { PublishedIndex, DEFAULT_MAX_VERSIONS } from '../lib/published-index.js'
import { TreeStore } from '../lib/store.js'
function mktemp (prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'))
}
function fakeLockfile (name, version, opts = {}) {
const ts = opts.publishedAt || new Date().toISOString()
return {
name,
version,
runtime: 'node',
rootTreeHash: opts.rootTreeHash || ('roothash-' + version),
publishedAt: ts,
deps: opts.deps || []
}
}
// ── PublishedIndex retention ─────────────────────────────────────
test('PublishedIndex — default is latest-only (DEFAULT_MAX_VERSIONS=1)', (t) => {
t.is(DEFAULT_MAX_VERSIONS, 1, 'default policy is latest-only')
})
test('PublishedIndex — caps to maxVersions on add', (t) => {
const dir = mktemp('pgh-idx')
const indexPath = path.join(dir, 'index.json')
// Force maxVersions=3 here so the test exercises the trimming logic
// independently of the global default.
const idx = new PublishedIndex({ path: indexPath, maxVersions: 3 })
for (let i = 1; i <= 5; i++) {
idx.add(fakeLockfile('appA', `0.${i}.0`, {
publishedAt: new Date(2026, 4, i).toISOString()
}))
}
const fresh = new PublishedIndex({ path: indexPath, maxVersions: 3 })
const versions = fresh.list().map(v => v.version)
t.is(versions.length, 3, 'cap = 3 (explicit override)')
t.absent(versions.includes('0.1.0'), 'oldest dropped')
t.absent(versions.includes('0.2.0'), 'second-oldest dropped')
t.ok(versions.includes('0.3.0'), '3rd kept')
t.ok(versions.includes('0.4.0'), '4th kept')
t.ok(versions.includes('0.5.0'), 'newest kept')
fs.rmSync(dir, { recursive: true, force: true })
})
test('PublishedIndex — latest-only default keeps just the newest', (t) => {
const dir = mktemp('pgh-idx')
const indexPath = path.join(dir, 'index.json')
const idx = new PublishedIndex({ path: indexPath }) // default policy
for (let i = 1; i <= 5; i++) {
idx.add(fakeLockfile('appA', `0.${i}.0`, {
publishedAt: new Date(2026, 4, i).toISOString()
}))
}
const versions = idx.list().map(v => v.version)
t.is(versions.length, 1, 'only 1 version kept by default')
t.is(versions[0], '0.5.0', 'newest preserved')
fs.rmSync(dir, { recursive: true, force: true })
})
test('PublishedIndex — pinned versions survive past the cap', (t) => {
const dir = mktemp('pgh-idx')
const indexPath = path.join(dir, 'index.json')
// Use maxVersions=3 so the test reads cleanly regardless of default.
const idx = new PublishedIndex({ path: indexPath, maxVersions: 3 })
idx.add(fakeLockfile('appA', '0.1.0', { publishedAt: new Date(2026, 4, 1).toISOString() }))
idx.add(fakeLockfile('appA', '0.2.0', { publishedAt: new Date(2026, 4, 2).toISOString() }))
t.ok(idx.pinVersion('appA', '0.1.0'), 'pin returns true on match')
for (let i = 3; i <= 6; i++) {
idx.add(fakeLockfile('appA', `0.${i}.0`, {
publishedAt: new Date(2026, 4, i).toISOString()
}))
}
const versions = idx.list()
const pinned = versions.find(v => v.version === '0.1.0')
t.ok(pinned, '0.1.0 still in index (pinned)')
t.ok(pinned.pinned, 'pinned flag preserved')
// 1 pinned (0.1.0) + 3 unpinned (last 3 of 0.2-0.6) = 4 total
t.is(versions.length, 4, 'pinned + cap = 4')
fs.rmSync(dir, { recursive: true, force: true })
})
test('PublishedIndex — strips _sourcePath from stored deps', (t) => {
const dir = mktemp('pgh-idx')
const indexPath = path.join(dir, 'index.json')
const idx = new PublishedIndex({ path: indexPath })
idx.add(fakeLockfile('appA', '0.1.0', {
deps: [
{ name: 'foo', version: '1.0.0', tree: 'abc', _sourcePath: '/tmp/build-XYZ/node_modules/foo' },
{ name: 'bar', version: '2.0.0', tree: 'def', _sourcePath: '/tmp/build-XYZ/node_modules/bar' }
]
}))
const raw = fs.readFileSync(indexPath, 'utf8')
t.absent(raw.includes('_sourcePath'), '_sourcePath not on disk')
t.absent(raw.includes('/tmp/build-XYZ'), 'publisher-local path stripped')
// But name/version/tree survive — that's what conflict detection needs.
t.ok(raw.includes('"name": "foo"'))
t.ok(raw.includes('"tree": "abc"'))
fs.rmSync(dir, { recursive: true, force: true })
})
test('PublishedIndex — rewriteWithRetention is idempotent + cleans up legacy entries', (t) => {
const dir = mktemp('pgh-idx')
const indexPath = path.join(dir, 'index.json')
// Hand-craft a legacy index with 10 versions for one app + _sourcePath leakage.
const legacy = {
publishedAt: '2026-01-01T00:00:00Z',
apps: {
appA: {
versions: Array.from({ length: 10 }, (_, i) => ({
version: `0.${i}.0`,
rootTreeHash: 'r' + i,
publishedAt: new Date(2026, 0, i + 1).toISOString(),
lockfile: {
name: 'appA',
version: `0.${i}.0`,
deps: [{ name: 'x', version: '1', tree: 't', _sourcePath: '/leaks/' + i }]
}
}))
}
}
}
fs.writeFileSync(indexPath, JSON.stringify(legacy))
const idx = new PublishedIndex({ path: indexPath })
idx.rewriteWithRetention()
const after = new PublishedIndex({ path: indexPath })
const versions = after.list()
t.is(versions.length, DEFAULT_MAX_VERSIONS, 'truncated to cap')
const raw = fs.readFileSync(indexPath, 'utf8')
t.absent(raw.includes('_sourcePath'), '_sourcePath stripped from surviving lockfiles')
t.absent(raw.includes('/leaks/'), 'publisher-local paths gone')
// Run again — same result (idempotent).
after.rewriteWithRetention()
const final = new PublishedIndex({ path: indexPath })
t.is(final.list().length, DEFAULT_MAX_VERSIONS, 'still capped after second pass')
fs.rmSync(dir, { recursive: true, force: true })
})
// ── TreeStore.setAppRefs ─────────────────────────────────────────
test('TreeStore.setAppRefs — atomically replaces an app\'s ref set', (t) => {
const root = mktemp('pgh-store')
const store = new TreeStore({ root })
store.init()
// Seed: appA references {h1, h2, h3} via the legacy add path.
store.addRef('h1', 'appA')
store.addRef('h2', 'appA')
store.addRef('h3', 'appA')
// Another app refs h2 — should NOT be touched by appA's setAppRefs.
store.addRef('h2', 'appB')
// setAppRefs to a new set {h2, h4} for appA — h1 and h3 must drop,
// h4 must appear, and appB's hold on h2 must survive.
store.setAppRefs('appA', ['h2', 'h4'])
t.alike(store.getRefs('h1'), [], 'h1 cleared')
t.alike(store.getRefs('h3'), [], 'h3 cleared')
// h2 still has both appA (untouched — already in want) and appB
// (separate ownership). Order is implementation-defined; compare sets.
const h2Refs = new Set(store.getRefs('h2'))
t.ok(h2Refs.has('appA') && h2Refs.has('appB'), 'h2 still has appA + appB')
t.is(h2Refs.size, 2, 'h2 has exactly two refs')
t.alike(store.getRefs('h4'), ['appA'], 'h4 picked up by appA')
fs.rmSync(root, { recursive: true, force: true })
})
test('TreeStore.clearAppRefs — drops every ref held by an app', (t) => {
const root = mktemp('pgh-store')
const store = new TreeStore({ root })
store.init()
store.addRef('h1', 'appA')
store.addRef('h2', 'appA')
store.addRef('h2', 'appB')
store.clearAppRefs('appA')
t.alike(store.getRefs('h1'), [], 'h1 cleared')
t.alike(store.getRefs('h2'), ['appB'], 'h2 keeps appB')
fs.rmSync(root, { recursive: true, force: true })
})
// ── Blob layer (NI-3 cross-tree hardlink dedup) ──────────────────
test('blob layer — same content across two trees shares one inode', async (t) => {
const root = mktemp('pgh-blob')
const store = new TreeStore({ root })
store.init()
// Two distinct trees containing the same file content (same hash).
const { KIND_FILE, computeTreeHash } = await import('hyper-overlay/tree')
const crypto = await import('hypercore-crypto')
const sharedBytes = Buffer.from('shared payload — both trees see this')
const sharedHash = crypto.default.data(sharedBytes).toString('hex')
function makeTree (filename) {
const entries = [{ kind: KIND_FILE, exec: 0, name: filename, childHash: sharedHash, size: sharedBytes.length }]
return { hash: computeTreeHash(entries), entries }
}
const treeA = makeTree('a.txt')
const treeB = makeTree('b.txt')
const fetcher = {
fetchContent: async (h) => h === sharedHash ? sharedBytes : null,
fetchTree: async () => { throw new Error('unused') }
}
await store.materialize(treeA.hash, treeA.entries, fetcher)
await store.materialize(treeB.hash, treeB.entries, fetcher)
// Both tree dirs exist
const aFile = path.join(store.treePath(treeA.hash), 'a.txt')
const bFile = path.join(store.treePath(treeB.hash), 'b.txt')
t.ok(fs.existsSync(aFile))
t.ok(fs.existsSync(bFile))
// Same inode (hardlinks share inode number)
const aStat = fs.statSync(aFile)
const bStat = fs.statSync(bFile)
t.is(aStat.ino, bStat.ino, 'tree A and tree B share the same inode for shared content')
// The blob file itself is the third hardlink (or the original)
t.ok(aStat.nlink >= 3, 'blob + 2 tree-dir links = nlink ≥ 3')
// Blob lives in the sharded blobs dir
const blobPath = store.blobPath(sharedHash)
t.ok(fs.existsSync(blobPath), 'blob is in store/blobs/<aa>/<rest>')
t.is(fs.statSync(blobPath).ino, aStat.ino, 'blob is the same inode')
fs.rmSync(root, { recursive: true, force: true })
})
test('blob layer — gc unlinks orphan blobs after tree gc', async (t) => {
const root = mktemp('pgh-blob-gc')
const store = new TreeStore({ root })
store.init()
const { KIND_FILE, computeTreeHash } = await import('hyper-overlay/tree')
const crypto = await import('hypercore-crypto')
const uniqueBytes = Buffer.from('only one tree refs me')
const uniqueHash = crypto.default.data(uniqueBytes).toString('hex')
const entries = [{ kind: KIND_FILE, exec: 0, name: 'lonely.txt', childHash: uniqueHash, size: uniqueBytes.length }]
const treeHash = computeTreeHash(entries)
const fetcher = {
fetchContent: async (h) => h === uniqueHash ? uniqueBytes : null,
fetchTree: async () => { throw new Error('unused') }
}
await store.materialize(treeHash, entries, fetcher)
t.ok(fs.existsSync(store.blobPath(uniqueHash)), 'blob exists after materialize')
// Tree has no refs → gc-eligible
const result = store.gc({ maxAgeDays: 0 })
t.ok(result.removed.includes(treeHash), 'tree dir removed')
t.is(result.blobsRemoved, 1, 'orphan blob unlinked')
t.absent(fs.existsSync(store.blobPath(uniqueHash)), 'blob gone after gc')
fs.rmSync(root, { recursive: true, force: true })
})
test('blob layer — gc keeps blobs still referenced by surviving trees', async (t) => {
const root = mktemp('pgh-blob-keep')
const store = new TreeStore({ root })
store.init()
const { KIND_FILE, computeTreeHash } = await import('hyper-overlay/tree')
const crypto = await import('hypercore-crypto')
const sharedBytes = Buffer.from('shared content')
const sharedHash = crypto.default.data(sharedBytes).toString('hex')
function entries (name) { return [{ kind: KIND_FILE, exec: 0, name, childHash: sharedHash, size: sharedBytes.length }] }
const eA = entries('a.txt'); const hA = computeTreeHash(eA)
const eB = entries('b.txt'); const hB = computeTreeHash(eB)
const fetcher = {
fetchContent: async () => sharedBytes,
fetchTree: async () => { throw new Error('unused') }
}
await store.materialize(hA, eA, fetcher)
await store.materialize(hB, eB, fetcher)
// Pin treeB so it survives gc; treeA is orphan
store.pin(hB)
const result = store.gc({ maxAgeDays: 0 })
t.ok(result.removed.includes(hA), 'unpinned tree A removed')
t.absent(result.removed.includes(hB), 'pinned tree B kept')
t.is(result.blobsRemoved, 0, 'blob NOT removed because tree B still references it')
t.ok(fs.existsSync(store.blobPath(sharedHash)), 'shared blob preserved')
fs.rmSync(root, { recursive: true, force: true })
})
// ── Age-gated gc ─────────────────────────────────────────────────
test('TreeStore.gc — maxAgeDays protects fresh unreferenced trees', (t) => {
const root = mktemp('pgh-store')
const store = new TreeStore({ root })
store.init()
// Hand-create two "trees" — one fresh, one old — both unreferenced.
// The dependencies dir holds the tree content; meta records pulledAt.
for (const [hash, ageDays] of [['fresh', 1], ['old', 30]]) {
fs.mkdirSync(store.treePath(hash))
fs.writeFileSync(store.metaPath(hash), JSON.stringify({
pulledAt: Date.now() - (ageDays * 24 * 60 * 60 * 1000),
pinned: false
}))
}
// With maxAgeDays=14, "fresh" (1d) must survive, "old" (30d) must go.
const result = store.gc({ maxAgeDays: 14 })
t.alike(result.removed, ['old'], 'old swept, fresh protected')
t.ok(result.kept.includes('fresh'), 'fresh kept')
// Sanity: maxAgeDays=0 (or undefined) reverts to the legacy "no grace" behavior.
// Re-create the trees and verify.
fs.mkdirSync(store.treePath('fresh2'))
fs.writeFileSync(store.metaPath('fresh2'), JSON.stringify({
pulledAt: Date.now(),
pinned: false
}))
const noGrace = store.gc({ maxAgeDays: 0 })
t.ok(noGrace.removed.includes('fresh2'), 'fresh2 swept with no grace')
fs.rmSync(root, { recursive: true, force: true })
})
test('TreeStore.gc — maxAgeDays still respects pinned + referenced', (t) => {
const root = mktemp('pgh-store')
const store = new TreeStore({ root })
store.init()
for (const [hash, ageDays] of [['pinned-old', 100], ['refd-old', 100], ['orphan-old', 100]]) {
fs.mkdirSync(store.treePath(hash))
fs.writeFileSync(store.metaPath(hash), JSON.stringify({
pulledAt: Date.now() - (ageDays * 24 * 60 * 60 * 1000),
pinned: false
}))
}
store.pin('pinned-old')
store.addRef('refd-old', 'someApp')
const result = store.gc({ maxAgeDays: 14 })
t.alike(result.removed, ['orphan-old'], 'only the unreferenced + unpinned old tree dropped')
t.ok(result.kept.includes('pinned-old'))
t.ok(result.kept.includes('refd-old'))
fs.rmSync(root, { recursive: true, force: true })
})
// ── Exec-bit round-trip (regression: blob layer dropped exec bit) ─
test('blob layer — exec bit preserved on materialized files', async (t) => {
const root = mktemp('pgh-exec')
const store = new TreeStore({ root })
store.init()
const { KIND_FILE, computeTreeHash, walkTree } = await import('hyper-overlay/tree')
const crypto = await import('hypercore-crypto')
const execBytes = Buffer.from('#!/bin/sh\necho hi\n')
const dataBytes = Buffer.from('{"hi":1}\n')
const execHash = crypto.default.data(execBytes).toString('hex')
const dataHash = crypto.default.data(dataBytes).toString('hex')
const entries = [
{ kind: KIND_FILE, exec: 1, name: 'run.sh', childHash: execHash, size: execBytes.length },
{ kind: KIND_FILE, exec: 0, name: 'data.json', childHash: dataHash, size: dataBytes.length }
]
const treeHash = computeTreeHash(entries)
const fetcher = {
fetchContent: async (h) => h === execHash ? execBytes : (h === dataHash ? dataBytes : null),
fetchTree: async () => { throw new Error('unused') }
}
await store.materialize(treeHash, entries, fetcher)
const treeDir = store.treePath(treeHash)
const runMode = fs.statSync(path.join(treeDir, 'run.sh')).mode & 0o777
const dataMode = fs.statSync(path.join(treeDir, 'data.json')).mode & 0o777
// Materialized blobs are read-only by design (no owner-write bit) so an
// in-place write can't mutate a hardlinked content blob; exec bit preserved.
t.is(runMode, 0o555, 'exec file is 0555 (read-only, exec preserved)')
t.is(dataMode, 0o444, 'non-exec file is 0444 (read-only)')
// Round-trip: walkTree of the materialized dir must produce the same tree hash
const trees = walkTree(treeDir)
t.is(trees.get('').hash, treeHash, 'walkTree of materialized dir matches publisher hash')
fs.rmSync(root, { recursive: true, force: true })
})