OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-runtime commits / ff15edc

pgh-runtime: add installRelease — fetch the full dependency closure, not just the root tree
pgh-runtime's installTree fetched only a single (root) tree and its nested subdirs. An app's node_modules deps are SEPARATE content-addressed trees listed in release.deps (each with its own tree hash + per-host prebuild), so fetching only the root produced a BROKEN install — which is exactly why pear-git-v3 constructs a PghRuntime but refuses to call checkNow() ("pgh-runtime only fetches root trees, not dep closures"). The gap was invisible because the tests mock the store/fetcher with synthetic single-tree fixtures and no real materialize. Add installRelease(store, release, fetcher, opts): 1. fetch the FULL closure (root + every dep.tree + prebuilds[host]) via installTree, then 2. hand it to an INJECTED materializer ({store, appName, rootTreeHash, deps, host}) — same dependency-injection model as fetcher/store, so pgh-runtime still has zero dependency on the pgh CLI. Omit the materializer for store-only (closure fetched, layout not wired). _check() now uses installRelease (deps=[] → identical to old behavior, so existing callers are unchanged). Tests: GAP doc (installTree skips deps), full-closure fetch, foreign-host prebuild excluded, materializer invocation, and PghRuntime end-to-end closure install. 16/16 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ff15edccd14ff4863c737821a040ba93dbdd7506
by plankatron <fleeky@gmail.com> · 2026-06-21T00:06:18.000Z

Changed files (3) +179 -3

index.js +83 -1
……@@ -56,6 +56,9 @@ export class PghRuntime extends EventEmitter {
5656 * @param {object} opts.store TreeStore-compatible (hasTree / materialize / init)
5757 * @param {number} [opts.interval] ms between polls (default 30m; 0 = no polling)
5858 * @param {boolean} [opts.startImmediately] do an initial check on start (default true)
59 * @param {string} [opts.host] host label for per-arch prebuild trees (e.g. "linux-x64")
60 * @param {function} [opts.materialize] injected materializer ({ store, appName, rootTreeHash, deps, host })
61 * — pgh's node materializer or any compatible fn. Omit for store-only.
5962 */
6063 constructor (opts = {}) {
6164 super()
……@@ -69,6 +72,8 @@ export class PghRuntime extends EventEmitter {
6972 this.store = opts.store
7073 this.interval = opts.interval != null ? opts.interval : DEFAULT_INTERVAL
7174 this.startImmediately = opts.startImmediately !== false
75 this.host = opts.host || null
76 this.materialize = typeof opts.materialize === 'function' ? opts.materialize : null
7277
7378 this.latest = null
7479 this.updating = false
……@@ -129,7 +134,14 @@ export class PghRuntime extends EventEmitter {
129134 this.emit('updating', release)
130135
131136 try {
132 const result = await installTree(this.store, release.treeHash, this.fetcher, {
137 // Full RELEASE install — root tree PLUS its dependency closure (and,
138 // when a materializer is injected, the wired install layout). Using
139 // installTree alone here fetched only the root tree → a broken install
140 // for any app with deps (see installRelease).
141 const result = await installRelease(this.store, release, this.fetcher, {
142 host: this.host,
143 materialize: this.materialize,
144 appName: this.name,
133145 source: 'pgh-runtime:' + this.name
134146 })
135147 this.newTreeHash = release.treeHash
……@@ -215,3 +227,73 @@ export async function installTree (store, rootHash, fetcher, opts = {}) {
215227 await visit(rootHash)
216228 return { materialized, skipped }
217229}
230
231/**
232 * Install a full RELEASE — the root tree PLUS its dependency closure.
233 *
234 * `installTree` fetches a single tree and its nested subdirectories, but an
235 * app's node_modules dependencies are SEPARATE content-addressed trees listed
236 * in the release's `deps` array (each with its own tree hash and a per-host
237 * prebuild tree). Fetching only the root tree therefore yields a BROKEN
238 * install — the deps are never fetched and node_modules is never wired. (This
239 * is exactly why pear-git-v3 constructs a PghRuntime but refuses to call
240 * checkNow(): "pgh-runtime only fetches root trees, not dep closures, so using
241 * it here would produce a broken install.")
242 *
243 * installRelease closes that gap:
244 * 1. fetch the FULL closure (root + every dep.tree + prebuilds[host]) into
245 * the store via installTree, then
246 * 2. hand the closure to a materializer that wires the install layout
247 * (hardlink deps + symlink node_modules).
248 *
249 * The materializer is INJECTED (`opts.materialize`) — same dependency-injection
250 * philosophy as fetcher/store — so pgh-runtime stays free of any dependency on
251 * the pgh CLI's materializer. pgh's node materializer
252 * (`materialize({ store, appName, rootTreeHash, deps, host })`) is the canonical
253 * one; any function with that shape works. Without a materializer the closure
254 * is fetched into the store but the install layout is not wired (store-only).
255 *
256 * @param {object} store TreeStore-compatible (init / hasTree / materialize)
257 * @param {object} release { treeHash, deps?: [{ name, version, tree, prebuilds }], name? }
258 * @param {object} fetcher { getTree, getContent }
259 * @param {object} [opts] { host?, appName?, materialize?, source? }
260 * @returns {Promise<{ closure: number, materialized: number, skipped: number, install: any }>}
261 */
262export async function installRelease (store, release, fetcher, opts = {}) {
263 if (!release || !release.treeHash) {
264 throw new Error('installRelease requires release.treeHash')
265 }
266 store.init()
267
268 const host = opts.host || null
269 const deps = Array.isArray(release.deps) ? release.deps : []
270
271 // Build the full closure: root tree + each dep's tree + its per-host prebuild.
272 const closureHashes = [release.treeHash]
273 for (const dep of deps) {
274 if (dep.tree) closureHashes.push(dep.tree)
275 if (host && dep.prebuilds && dep.prebuilds[host]) closureHashes.push(dep.prebuilds[host])
276 }
277
278 const source = opts.source || ('pgh-runtime:' + (release.name || 'app'))
279 let materialized = 0
280 let skipped = 0
281 for (const hash of closureHashes) {
282 const r = await installTree(store, hash, fetcher, { source })
283 materialized += r.materialized
284 skipped += r.skipped
285 }
286
287 let install = null
288 if (typeof opts.materialize === 'function') {
289 install = await opts.materialize({
290 store,
291 appName: opts.appName || release.name,
292 rootTreeHash: release.treeHash,
293 deps,
294 host
295 })
296 }
297
298 return { closure: closureHashes.length, materialized, skipped, install }
299}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh-runtime",
3 "version": "0.1.2",
3 "version": "0.1.3",
44 "description": "Per-app OTA updater for pgh-installed apps — polls a release source, materializes new trees into the pgh store",
55 "type": "module",
66 "main": "index.js",
test/runtime.test.js +95 -1
……@@ -9,7 +9,7 @@
99import test from 'brittle'
1010import crypto from 'hypercore-crypto'
1111
12import { PghRuntime, installTree } from '../index.js'
12import { PghRuntime, installTree, installRelease } from '../index.js'
1313import { KIND_FILE, computeTreeHash } from 'hyper-overlay/tree'
1414
1515function hashBytes (b) { return crypto.data(b).toString('hex') }
……@@ -162,6 +162,100 @@ test('readyToApply descriptor after update', async (t) => {
162162 await runtime.stop()
163163})
164164
165// ── Dependency closure (installRelease) ─────────────────────
166
167test('GAP: installTree fetches only the root tree, not the dep closure', async (t) => {
168 const fetcher = makeFetcher()
169 const depH = fetcher.build([{ name: 'index.js', content: 'module.exports = "dep"' }])
170 const rootH = fetcher.build([{ name: 'index.js', content: 'require("dep")' }])
171 const store = makeMemoryStore()
172
173 await installTree(store, rootH, fetcher)
174
175 t.ok(store.hasTree(rootH), 'root tree fetched')
176 t.absent(store.hasTree(depH), 'dep tree NOT fetched — this is the gap installRelease closes')
177})
178
179test('installRelease fetches the FULL closure (root + dep.tree + prebuilds[host])', async (t) => {
180 const fetcher = makeFetcher()
181 const depH = fetcher.build([{ name: 'index.js', content: 'module.exports = "dep"' }])
182 const preH = fetcher.build([{ name: 'native.node', content: 'PREBUILT' }])
183 const rootH = fetcher.build([{ name: 'index.js', content: 'require("dep")' }])
184 const store = makeMemoryStore()
185
186 const release = {
187 name: 'app',
188 treeHash: rootH,
189 deps: [{ name: 'dep', version: '1.0.0', tree: depH, prebuilds: { 'linux-x64': preH } }]
190 }
191
192 const res = await installRelease(store, release, fetcher, { host: 'linux-x64' })
193
194 t.ok(store.hasTree(rootH), 'root tree present')
195 t.ok(store.hasTree(depH), 'dep tree present (closure fetched)')
196 t.ok(store.hasTree(preH), 'per-host prebuild tree present')
197 t.is(res.closure, 3, 'reported 3 closure trees')
198})
199
200test('installRelease ignores a foreign-host prebuild', async (t) => {
201 const fetcher = makeFetcher()
202 const depH = fetcher.build([{ name: 'index.js', content: 'x' }])
203 const preLinux = fetcher.build([{ name: 'n', content: 'LINUX' }])
204 const preMac = fetcher.build([{ name: 'n', content: 'MAC' }])
205 const rootH = fetcher.build([{ name: 'index.js', content: 'y' }])
206 const store = makeMemoryStore()
207
208 const release = {
209 name: 'app',
210 treeHash: rootH,
211 deps: [{ name: 'dep', version: '1.0.0', tree: depH, prebuilds: { 'linux-x64': preLinux, 'darwin-arm64': preMac } }]
212 }
213 await installRelease(store, release, fetcher, { host: 'linux-x64' })
214
215 t.ok(store.hasTree(preLinux), 'this host prebuild fetched')
216 t.absent(store.hasTree(preMac), 'foreign-host prebuild NOT fetched')
217})
218
219test('installRelease hands the closure to an injected materializer', async (t) => {
220 const fetcher = makeFetcher()
221 const depH = fetcher.build([{ name: 'index.js', content: 'd' }])
222 const rootH = fetcher.build([{ name: 'index.js', content: 'r' }])
223 const store = makeMemoryStore()
224 const release = { name: 'app', treeHash: rootH, deps: [{ name: 'dep', version: '1.0.0', tree: depH }] }
225
226 let call = null
227 await installRelease(store, release, fetcher, {
228 host: 'linux-x64',
229 appName: 'app',
230 materialize: (args) => { call = args; return { installPath: '/fake/app' } }
231 })
232
233 t.ok(call, 'materializer was invoked')
234 t.is(call.rootTreeHash, rootH, 'rootTreeHash passed')
235 t.is(call.appName, 'app', 'appName passed')
236 t.is(call.host, 'linux-x64', 'host passed')
237 t.alike(call.deps.map(d => d.name), ['dep'], 'deps passed through')
238})
239
240test('PghRuntime._check now installs the full closure (not just root)', async (t) => {
241 const fetcher = makeFetcher()
242 const depH = fetcher.build([{ name: 'index.js', content: 'dep' }])
243 const rootH = fetcher.build([{ name: 'index.js', content: 'app' }])
244 fetcher.setLatest({
245 name: 'app', version: '1.0.0', treeHash: rootH,
246 deps: [{ name: 'dep', version: '1.0.0', tree: depH }]
247 })
248 const store = makeMemoryStore()
249 const runtime = new PghRuntime({ name: 'app', fetcher, store, interval: 0, host: 'linux-x64' })
250
251 await runtime.start()
252
253 t.ok(store.hasTree(rootH), 'root installed by runtime')
254 t.ok(store.hasTree(depH), 'dep closure installed by runtime (the fix)')
255 t.is(runtime.updated, true)
256 await runtime.stop()
257})
258
165259// ── Errors ──────────────────────────────────────────────────
166260
167261test('fetcher throws → error event', async (t) => {