/**
* TreeStore tests — P.10 Phase 0.5c
*/
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import crypto from 'hypercore-crypto'
import { TreeStore, MATERIALIZE_CONCURRENCY } from '../lib/store.js'
import { KIND_FILE, KIND_DIR, KIND_SYMLINK, computeTreeHash } from 'hyper-overlay/tree'
function mktemp (prefix = 'pgh-store') {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'))
}
function hashBytes (buf) {
return crypto.data(buf).toString('hex')
}
/**
* Build a mock fetcher that resolves from pre-registered maps:
* contents: { <contentHash>: Buffer }
* trees: { <treeHash>: entries[] }
*/
function mockFetcher (contents = {}, trees = {}) {
return {
fetchContent: async (hash) => {
if (!contents[hash]) throw new Error('no content registered for ' + hash)
return contents[hash]
},
fetchTree: async (hash) => {
if (!trees[hash]) throw new Error('no tree registered for ' + hash)
return trees[hash]
}
}
}
// ── Init / basic ops ─────────────────────────────────────────
test('TreeStore — init creates expected dirs', (t) => {
const root = mktemp()
const store = new TreeStore({ root })
store.init()
t.ok(fs.existsSync(path.join(root, 'dependencies')))
t.ok(fs.existsSync(path.join(root, 'apps')))
t.ok(fs.existsSync(path.join(root, 'meta')))
t.ok(fs.existsSync(path.join(root, 'tmp')))
t.ok(fs.existsSync(path.join(root, 'refs.json')))
fs.rmSync(root, { recursive: true, force: true })
})
test('TreeStore — hasTree reports false initially', (t) => {
const root = mktemp()
const store = new TreeStore({ root })
t.absent(store.hasTree('a'.repeat(64)))
fs.rmSync(root, { recursive: true, force: true })
})
// ── Materialization ──────────────────────────────────────────
test('materialize — single file tree', async (t) => {
const root = mktemp()
const store = new TreeStore({ root })
const content = Buffer.from('hello world')
const contentHash = hashBytes(content)
const entries = [{ kind: KIND_FILE, exec: 0, name: 'hello.txt', childHash: contentHash, size: content.length }]
// materialize()'s round-trip guard recomputes the tree hash from the
// on-disk result and rejects a mismatch, so the hash must be the real one.
const treeHash = computeTreeHash(entries)
const fetcher = mockFetcher({ [contentHash]: content })
await store.materialize(treeHash, entries, { ...fetcher, source: 'test' })
t.ok(store.hasTree(treeHash))
const materialized = path.join(store.treePath(treeHash), 'hello.txt')
t.ok(fs.existsSync(materialized))
t.is(fs.readFileSync(materialized, 'utf8'), 'hello world')
fs.rmSync(root, { recursive: true, force: true })
})
test('materialize — executable bit preserved', async (t) => {
const root = mktemp()
const store = new TreeStore({ root })
const content = Buffer.from('#!/bin/sh\necho hi\n')
const contentHash = hashBytes(content)
const entries = [{ kind: KIND_FILE, exec: 1, name: 'run.sh', childHash: contentHash, size: content.length }]
const treeHash = computeTreeHash(entries)
await store.materialize(treeHash, entries, mockFetcher({ [contentHash]: content }))
const materialized = path.join(store.treePath(treeHash), 'run.sh')
const stat = fs.statSync(materialized)
t.ok(stat.mode & 0o100, 'owner-exec bit set')
fs.rmSync(root, { recursive: true, force: true })
})
test('materialize — nested subtree', async (t) => {
const root = mktemp()
const store = new TreeStore({ root })
const utilContent = Buffer.from('module.exports = {}')
const utilHash = hashBytes(utilContent)
const subtreeEntries = [{ kind: KIND_FILE, exec: 0, name: 'util.js', childHash: utilHash, size: utilContent.length }]
const subtreeHash = computeTreeHash(subtreeEntries)
const rootEntries = [{ kind: KIND_DIR, exec: 0, name: 'lib', childHash: subtreeHash, size: utilContent.length }]
const rootHash = computeTreeHash(rootEntries)
const fetcher = mockFetcher({ [utilHash]: utilContent }, { [subtreeHash]: subtreeEntries })
await store.materialize(rootHash, rootEntries, fetcher)
const nestedFile = path.join(store.treePath(rootHash), 'lib', 'util.js')
t.ok(fs.existsSync(nestedFile))
t.is(fs.readFileSync(nestedFile, 'utf8'), 'module.exports = {}')
fs.rmSync(root, { recursive: true, force: true })
})
test('materialize — copy-fallback when hardlink is denied (EACCES / SELinux)', async (t) => {
const root = mktemp()
const store = new TreeStore({ root })
const content = Buffer.from('no-hardlink-here')
const contentHash = hashBytes(content)
const entries = [{ kind: KIND_FILE, exec: 0, name: 'f.txt', childHash: contentHash, size: content.length }]
const treeHash = computeTreeHash(entries)
// Simulate a policy that forbids link(2) — e.g. SELinux on a no-root
// Android node (celltron), where every linkSync throws EACCES. Before the
// fix, only EXDEV fell back to copy, so EACCES aborted the whole install.
const realLink = fs.linkSync
let linkAttempts = 0
fs.linkSync = () => { linkAttempts++; const e = new Error('EACCES: operation not permitted, link'); e.code = 'EACCES'; throw e }
t.teardown(() => { fs.linkSync = realLink })
await store.materialize(treeHash, entries, mockFetcher({ [contentHash]: content }))
t.ok(linkAttempts > 0, 'linkSync was attempted and denied')
t.ok(store.hasTree(treeHash), 'tree still materialized via copy-fallback')
const materialized = path.join(store.treePath(treeHash), 'f.txt')
t.is(fs.readFileSync(materialized, 'utf8'), 'no-hardlink-here', 'content correct after copy fallback')
fs.rmSync(root, { recursive: true, force: true })
})
test('materialize — fetches run concurrently, bounded, and tree round-trips', async (t) => {
const root = mktemp()
const store = new TreeStore({ root })
// A wide tree (more files than the concurrency bound) so overlap is
// observable and the bound is exercised.
const N = MATERIALIZE_CONCURRENCY + 8
const contents = {}
const entries = []
for (let i = 0; i < N; i++) {
const c = Buffer.from('file-' + i + '-' + 'x'.repeat(40))
const h = hashBytes(c)
contents[h] = c
entries.push({ kind: KIND_FILE, exec: 0, name: 'f' + i + '.txt', childHash: h, size: c.length })
}
const treeHash = computeTreeHash(entries)
let inFlight = 0
let maxInFlight = 0
const fetcher = {
fetchContent: async (h) => {
inFlight++
if (inFlight > maxInFlight) maxInFlight = inFlight
await new Promise((r) => setTimeout(r, 15)) // hold the slot so overlap is real
inFlight--
return contents[h]
},
fetchTree: async () => { throw new Error('no subtrees in this fixture') }
}
await store.materialize(treeHash, entries, { ...fetcher, source: 'test' })
// Correctness: materialize()'s own round-trip guard already ran; also spot-check.
t.ok(store.hasTree(treeHash), 'tree materialized + round-trips to its hash')
t.is(fs.readFileSync(path.join(store.treePath(treeHash), 'f0.txt'), 'utf8'), 'file-0-' + 'x'.repeat(40), 'file content correct')
// Parallelism: overlap actually happened, and never exceeded the bound.
t.ok(maxInFlight > 1, 'fetches overlapped (was serial before) — maxInFlight=' + maxInFlight)
t.ok(maxInFlight <= MATERIALIZE_CONCURRENCY, 'concurrency stayed within bound (' + MATERIALIZE_CONCURRENCY + ')')
fs.rmSync(root, { recursive: true, force: true })
})
test('materialize — idempotent (already-present tree is a no-op)', async (t) => {
const root = mktemp()
const store = new TreeStore({ root })
const content = Buffer.from('x')
const contentHash = hashBytes(content)
const entries = [{ kind: KIND_FILE, exec: 0, name: 'a', childHash: contentHash, size: 1 }]
const treeHash = computeTreeHash(entries)
let fetchCount = 0
const fetcher = {
fetchContent: async (h) => { fetchCount++; return content },
fetchTree: async () => { throw new Error('should not be called') }
}
await store.materialize(treeHash, entries, fetcher)
t.is(fetchCount, 1)
await store.materialize(treeHash, entries, fetcher)
t.is(fetchCount, 1, 'second materialize skipped fetch')
fs.rmSync(root, { recursive: true, force: true })
})
test('materialize — atomic: tmp cleaned up on error', async (t) => {
const root = mktemp()
const store = new TreeStore({ root })
const entries = [{ kind: KIND_FILE, exec: 0, name: 'a', childHash: 'z'.repeat(64), size: 1 }]
const treeHash = 'f'.repeat(64)
const fetcher = {
fetchContent: async () => { throw new Error('fetch failed') },
fetchTree: async () => { throw new Error('fetch failed') }
}
let err
try { await store.materialize(treeHash, entries, fetcher) } catch (e) { err = e }
t.ok(err, 'errored')
t.absent(store.hasTree(treeHash), 'tree not half-materialized')
// tmp dir should be empty
const tmpContents = fs.readdirSync(store.tmpDir)
t.is(tmpContents.length, 0, 'tmp cleaned up on error')
fs.rmSync(root, { recursive: true, force: true })
})
// ── Refcounts ────────────────────────────────────────────────
test('refs — add, query, remove', (t) => {
const root = mktemp()
const store = new TreeStore({ root })
const h = 'q'.repeat(64)
t.alike(store.getRefs(h), [])
store.addRef(h, 'app-a')
store.addRef(h, 'app-b')
store.addRef(h, 'app-a') // idempotent
t.alike(store.getRefs(h).sort(), ['app-a', 'app-b'])
store.removeRef(h, 'app-a')
t.alike(store.getRefs(h), ['app-b'])
store.removeRef(h, 'app-b')
t.alike(store.getRefs(h), [])
fs.rmSync(root, { recursive: true, force: true })
})
// ── Pinning ──────────────────────────────────────────────────
test('pin / unpin', (t) => {
const root = mktemp()
const store = new TreeStore({ root })
store.init()
const h = 'p'.repeat(64)
// Create a minimal meta entry
fs.mkdirSync(path.join(store.dependenciesDir, h))
store._writeMeta(h, { pulledAt: Date.now(), schema: TreeStore.BLOB_LAYER_SCHEMA })
t.absent(store.isPinned(h))
store.pin(h)
t.ok(store.isPinned(h))
store.unpin(h)
t.absent(store.isPinned(h))
fs.rmSync(root, { recursive: true, force: true })
})
// ── GC ───────────────────────────────────────────────────────
test('gc — removes unreferenced, keeps referenced + pinned', async (t) => {
const root = mktemp()
const store = new TreeStore({ root })
store.init()
// Three trees: A referenced, B pinned, C orphan
const hA = 'a'.repeat(64)
const hB = 'b'.repeat(64)
const hC = 'c'.repeat(64)
for (const h of [hA, hB, hC]) {
fs.mkdirSync(path.join(store.dependenciesDir, h))
store._writeMeta(h, { pulledAt: Date.now(), schema: TreeStore.BLOB_LAYER_SCHEMA })
}
store.addRef(hA, 'some-app')
store.pin(hB)
const result = store.gc()
t.alike(result.removed, [hC], 'only orphan removed')
t.ok(store.hasTree(hA))
t.ok(store.hasTree(hB))
t.absent(store.hasTree(hC))
fs.rmSync(root, { recursive: true, force: true })
})
test('gc --dry-run reports without deleting', async (t) => {
const root = mktemp()
const store = new TreeStore({ root })
store.init()
const h = 'd'.repeat(64)
fs.mkdirSync(path.join(store.dependenciesDir, h))
store._writeMeta(h, { pulledAt: Date.now(), schema: TreeStore.BLOB_LAYER_SCHEMA })
const result = store.gc({ dryRun: true })
t.alike(result.candidates, [h], 'reported as candidate')
t.alike(result.removed, [], 'nothing actually removed')
t.ok(store.hasTree(h), 'still present')
fs.rmSync(root, { recursive: true, force: true })
})
// ── Stats ────────────────────────────────────────────────────
test('stats — aggregates tree counts + sizes', async (t) => {
const root = mktemp()
const store = new TreeStore({ root })
store.init()
const content = Buffer.from('x'.repeat(100))
const contentHash = hashBytes(content)
// Tree 1 — referenced
const e1 = [{ kind: KIND_FILE, exec: 0, name: 'a', childHash: contentHash, size: 100 }]
const h1 = computeTreeHash(e1)
await store.materialize(h1, e1, mockFetcher({ [contentHash]: content }))
store.addRef(h1, 'app')
// Tree 2 — orphan
const e2 = [{ kind: KIND_FILE, exec: 0, name: 'b', childHash: contentHash, size: 100 }]
const h2 = computeTreeHash(e2)
await store.materialize(h2, e2, mockFetcher({ [contentHash]: content }))
const s = store.stats()
t.is(s.treeCount, 2)
t.is(s.referencedCount, 1)
t.is(s.unreferencedCount, 1)
t.ok(s.totalBytes >= 200)
fs.rmSync(root, { recursive: true, force: true })
})
test('listTrees — enumerates all with refs + pinned state', async (t) => {
const root = mktemp()
const store = new TreeStore({ root })
store.init()
const content = Buffer.from('ok')
const contentHash = hashBytes(content)
const eL = [{ kind: KIND_FILE, exec: 0, name: 'x', childHash: contentHash, size: 2 }]
const h = computeTreeHash(eL)
await store.materialize(h, eL, mockFetcher({ [contentHash]: content }))
store.addRef(h, 'one')
store.addRef(h, 'two')
store.pin(h)
const list = store.listTrees()
t.is(list.length, 1)
t.is(list[0].hash, h)
t.alike(list[0].refs.sort(), ['one', 'two'])
t.ok(list[0].pinned)
fs.rmSync(root, { recursive: true, force: true })
})
test('TreeStore — gc reports orphan apps and sweeps only on opt-in', (t) => {
const root = mktemp()
const store = new TreeStore({ root })
store.init()
// Two app installs on disk; only one is recorded in refs.json.
fs.mkdirSync(path.join(root, 'apps', 'kept-app'), { recursive: true })
fs.writeFileSync(path.join(root, 'apps', 'kept-app', 'index.js'), 'kept')
fs.mkdirSync(path.join(root, 'apps', 'orphan-app'), { recursive: true })
fs.writeFileSync(path.join(root, 'apps', 'orphan-app', 'index.js'), 'orphan')
fs.writeFileSync(store.refsPath, JSON.stringify({ deadbeef: ['kept-app'] }))
t.alike(store.listOrphanApps(), ['orphan-app'], 'only the unreferenced app is orphaned')
// Default + dryRun: report but never remove.
const dry = store.gc({ dryRun: true })
t.alike(dry.orphanApps, ['orphan-app'], 'gc reports orphan apps')
t.alike(dry.appsRemoved, [], 'dryRun removes nothing')
const noflag = store.gc({})
t.alike(noflag.appsRemoved, [], 'sweep is opt-in — default removes no apps')
t.ok(fs.existsSync(path.join(root, 'apps', 'orphan-app')), 'orphan dir still present without flag')
// Opt-in sweep removes only the orphan, keeps the referenced app.
const swept = store.gc({ sweepOrphanApps: true })
t.alike(swept.appsRemoved, ['orphan-app'], 'orphan removed on opt-in')
t.absent(fs.existsSync(path.join(root, 'apps', 'orphan-app')), 'orphan dir gone')
t.ok(fs.existsSync(path.join(root, 'apps', 'kept-app')), 'referenced app kept')
fs.rmSync(root, { recursive: true, force: true })
})