| … | … | @@ -56,6 +56,9 @@ export class PghRuntime extends EventEmitter { |
| 56 | 56 | * @param {object} opts.store TreeStore-compatible (hasTree / materialize / init) |
| 57 | 57 | * @param {number} [opts.interval] ms between polls (default 30m; 0 = no polling) |
| 58 | 58 | * @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. | |
| 59 | 62 | */ |
| 60 | 63 | constructor (opts = {}) { |
| 61 | 64 | super() |
| … | … | @@ -69,6 +72,8 @@ export class PghRuntime extends EventEmitter { |
| 69 | 72 | this.store = opts.store |
| 70 | 73 | this.interval = opts.interval != null ? opts.interval : DEFAULT_INTERVAL |
| 71 | 74 | this.startImmediately = opts.startImmediately !== false |
| 75 | this.host = opts.host || null | |
| 76 | this.materialize = typeof opts.materialize === 'function' ? opts.materialize : null | |
| 72 | 77 | |
| 73 | 78 | this.latest = null |
| 74 | 79 | this.updating = false |
| … | … | @@ -129,7 +134,14 @@ export class PghRuntime extends EventEmitter { |
| 129 | 134 | this.emit('updating', release) |
| 130 | 135 | |
| 131 | 136 | 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, | |
| 133 | 145 | source: 'pgh-runtime:' + this.name |
| 134 | 146 | }) |
| 135 | 147 | this.newTreeHash = release.treeHash |
| … | … | @@ -215,3 +227,73 @@ export async function installTree (store, rootHash, fetcher, opts = {}) { |
| 215 | 227 | await visit(rootHash) |
| 216 | 228 | return { materialized, skipped } |
| 217 | 229 | } |
| 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 | */ | |
| 262 | export 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 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pgh-runtime", |
| 3 | "version": "0.1.2", | |
| 3 | "version": "0.1.3", | |
| 4 | 4 | "description": "Per-app OTA updater for pgh-installed apps — polls a release source, materializes new trees into the pgh store", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | import test from 'brittle' |
| 10 | 10 | import crypto from 'hypercore-crypto' |
| 11 | 11 | |
| 12 | import { PghRuntime, installTree } from '../index.js' | |
| 12 | import { PghRuntime, installTree, installRelease } from '../index.js' | |
| 13 | 13 | import { KIND_FILE, computeTreeHash } from 'hyper-overlay/tree' |
| 14 | 14 | |
| 15 | 15 | function hashBytes (b) { return crypto.data(b).toString('hex') } |
| … | … | @@ -162,6 +162,100 @@ test('readyToApply descriptor after update', async (t) => { |
| 162 | 162 | await runtime.stop() |
| 163 | 163 | }) |
| 164 | 164 | |
| 165 | // ── Dependency closure (installRelease) ───────────────────── | |
| 166 | ||
| 167 | test('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 | ||
| 179 | test('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 | ||
| 200 | test('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 | ||
| 219 | test('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 | ||
| 240 | test('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 | ||
| 165 | 259 | // ── Errors ────────────────────────────────────────────────── |
| 166 | 260 | |
| 167 | 261 | test('fetcher throws → error event', async (t) => { |