OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / pgh-retention.test.js

437 lines · javascript ref:
/**
 * 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 })
})