OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-deploy commits / d814341

Add tree publish pass to DeployServer scan
P.10 Phase 0.5b. Additive to the existing file-index population — DeployServer._scan now also walks the directory tree via hyper-overlay's walkTree and persists every subtree into fileIndex.{putTree,putTreePath}. manifest.rootTreeHash exposes the root-level tree hash for consumers (pgh) that want to fetch subtrees by hash rather than enumerate files. Older clients that only know about file-level sync keep working unchanged — the tree layer is purely additive. Error handling: if walkTree throws (e.g. a name can't round-trip through normalizeName), the server still publishes at file level; rootTreeHash stays null and a 'tree-publish-error' event fires. Release doesn't fail from tree-publish issues alone. Events: - trees-indexed(count, rootHash) — after successful tree publish - tree-publish-error(err) — fallback path test/tree-publish.test.js (7 new tests, 23 asserts): - rootTreeHash populated on manifest - tree entries + subtrees stored in fileIndex - treepath mapping works for path → tree hash lookup - mtime-insensitive across rescans - content changes propagate to root hash - trees-indexed event fires with count - empty directory produces valid (empty) tree Existing e2e-release-sync.test.js still passes — no regression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
d8143413989f80762e9e2fa82b63fb3203dfef09
by plankatron <fleeky@gmail.com> · 2026-04-18T11:51:45.000Z

Changed files (2) +198 -1

index.js +26 -1
……@@ -28,6 +28,7 @@ import safetyCatch from 'safety-catch'
2828import { FileIndex } from 'hyper-overlay/file-index'
2929import { TransferManager } from 'hyper-overlay/transfer'
3030import { hashChunk } from 'hyper-overlay/chunker'
31import { walkTree } from 'hyper-overlay/tree'
3132import { attachProtocol } from './lib/protocol.js'
3233
3334const CHUNK_THRESHOLD = 1048576 // 1 MB — files below this transfer as whole blobs
……@@ -166,7 +167,8 @@ export class DeployServer extends ReadyResource {
166167 size: f.size,
167168 hash: null // populated lazily from fileIndex
168169 })),
169 totalSize
170 totalSize,
171 rootTreeHash: null // filled in by tree publish pass (0.5b)
170172 }
171173
172174 // Populate hashes from the index
……@@ -175,6 +177,29 @@ export class DeployServer extends ReadyResource {
175177 if (meta) f.hash = meta.contentHash
176178 }
177179
180 // 0.5b: tree publish pass — compute + persist tree hashes for every
181 // directory. Additive to the file-level index; existing client flows
182 // (file-by-path sync) remain unchanged. Clients that know about trees
183 // (pgh) can start from manifest.rootTreeHash and fetch subtrees.
184 try {
185 const trees = walkTree(this.dir)
186 for (const [relPath, tree] of trees) {
187 await this._fileIndex.putTree(tree.hash, {
188 entries: tree.entries,
189 size: tree.size
190 })
191 await this._fileIndex.putTreePath(relPath || '/', tree.hash)
192 }
193 const root = trees.get('')
194 if (root) this.manifest.rootTreeHash = root.hash
195 this.emit('trees-indexed', trees.size, this.manifest.rootTreeHash)
196 } catch (err) {
197 // Non-fatal: trees are an additive layer. If walkTree fails (e.g.
198 // a name that can't round-trip through normalizeName), fall back to
199 // file-level-only publishing. Caller sees rootTreeHash === null.
200 this.emit('tree-publish-error', err)
201 }
202
178203 this.emit('indexed', this.manifest.files.length, totalSize)
179204 }
180205
test/tree-publish.test.js +172 -0
……@@ -0,0 +1,172 @@
1/**
2 * Tree publish pass tests — P.10 Phase 0.5b
3 *
4 * Verifies that DeployServer populates the hyper-overlay tree index
5 * alongside the file index during _scan, and that manifest.rootTreeHash
6 * is set.
7 */
8
9import test from 'brittle'
10import fs from 'bare-fs'
11import path from 'bare-path'
12import os from 'bare-os'
13import crypto from 'hypercore-crypto'
14import { DeployServer } from '../index.js'
15
16function mktemp (prefix) {
17 const dir = path.join(os.tmpdir(), 'tree-publish-' + prefix + '-' + Date.now() + '-' + crypto.randomBytes(3).toString('hex'))
18 fs.mkdirSync(dir, { recursive: true })
19 return dir
20}
21
22function makeAppDir () {
23 const dir = mktemp('app')
24 // Mimic a small app layout
25 fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"test-app","version":"1.0.0"}')
26 fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("hello")')
27 fs.mkdirSync(path.join(dir, 'lib'))
28 fs.writeFileSync(path.join(dir, 'lib', 'util.js'), 'module.exports = {}')
29 fs.writeFileSync(path.join(dir, 'lib', 'helper.js'), 'exports.x = 1')
30 fs.mkdirSync(path.join(dir, 'assets'))
31 fs.writeFileSync(path.join(dir, 'assets', 'icon.png'), Buffer.alloc(256, 0xaa))
32 return dir
33}
34
35// ── Tests ─────────────────────────────────────────────────────
36
37test('DeployServer scan populates rootTreeHash', async (t) => {
38 const dir = makeAppDir()
39 const server = new DeployServer(dir)
40 await server.ready()
41
42 t.ok(server.manifest, 'manifest present')
43 t.ok(server.manifest.rootTreeHash, 'rootTreeHash set on manifest')
44 t.is(server.manifest.rootTreeHash.length, 64, 'hash is 32-byte hex (64 chars)')
45
46 await server.close()
47 fs.rmSync(dir, { recursive: true, force: true })
48})
49
50test('DeployServer stores tree entries in fileIndex', async (t) => {
51 const dir = makeAppDir()
52 const server = new DeployServer(dir)
53 await server.ready()
54
55 const rootHash = server.manifest.rootTreeHash
56 const root = await server._fileIndex.getTree(rootHash)
57 t.ok(root, 'root tree stored')
58 t.ok(Array.isArray(root.entries), 'root has entries list')
59
60 const names = root.entries.map(e => e.name).sort()
61 t.alike(names, ['assets', 'index.js', 'lib', 'package.json'], 'top-level entries present')
62
63 // lib/ subtree should also be stored
64 const libEntry = root.entries.find(e => e.name === 'lib')
65 t.ok(libEntry, 'lib entry in root')
66 t.is(libEntry.kind, 1, 'lib is directory (kind=1)')
67
68 const libTree = await server._fileIndex.getTree(libEntry.childHash)
69 t.ok(libTree, 'lib subtree stored by hash')
70 t.is(libTree.entries.length, 2, 'lib has 2 files')
71
72 await server.close()
73 fs.rmSync(dir, { recursive: true, force: true })
74})
75
76test('DeployServer treepath index lets you look up tree by path', async (t) => {
77 const dir = makeAppDir()
78 const server = new DeployServer(dir)
79 await server.ready()
80
81 const rootHashViaPath = await server._fileIndex.getTreePath('/')
82 t.is(rootHashViaPath, server.manifest.rootTreeHash, 'treepath / = rootTreeHash')
83
84 const libHashViaPath = await server._fileIndex.getTreePath('lib')
85 t.ok(libHashViaPath, 'lib path mapped to a hash')
86 const libTree = await server._fileIndex.getTree(libHashViaPath)
87 t.ok(libTree)
88 t.is(libTree.entries.length, 2)
89
90 await server.close()
91 fs.rmSync(dir, { recursive: true, force: true })
92})
93
94test('DeployServer — identical content produces identical rootTreeHash across rescans', async (t) => {
95 const dir = makeAppDir()
96
97 const server1 = new DeployServer(dir)
98 await server1.ready()
99 const h1 = server1.manifest.rootTreeHash
100 await server1.close()
101
102 // Touch all files to change mtimes — hash must NOT change
103 for (const f of ['index.js', 'package.json', 'lib/util.js', 'lib/helper.js', 'assets/icon.png']) {
104 const p = path.join(dir, f)
105 const past = new Date(Date.now() - 1000000)
106 fs.utimesSync(p, past, past)
107 }
108
109 const server2 = new DeployServer(dir)
110 await server2.ready()
111 const h2 = server2.manifest.rootTreeHash
112 await server2.close()
113
114 t.is(h1, h2, 'rootTreeHash is mtime-insensitive')
115
116 fs.rmSync(dir, { recursive: true, force: true })
117})
118
119test('DeployServer — changed content changes rootTreeHash', async (t) => {
120 const dir = makeAppDir()
121
122 const server1 = new DeployServer(dir)
123 await server1.ready()
124 const h1 = server1.manifest.rootTreeHash
125 await server1.close()
126
127 // Change one file's content
128 fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("changed")')
129
130 const server2 = new DeployServer(dir)
131 await server2.ready()
132 const h2 = server2.manifest.rootTreeHash
133 await server2.close()
134
135 t.unlike(h1, h2, 'content change propagates to root hash')
136
137 fs.rmSync(dir, { recursive: true, force: true })
138})
139
140test('DeployServer — emits trees-indexed event with count', async (t) => {
141 const dir = makeAppDir()
142 const server = new DeployServer(dir)
143
144 let treesIndexed = null
145 server.on('trees-indexed', (count, rootHash) => {
146 treesIndexed = { count, rootHash }
147 })
148
149 await server.ready()
150 t.ok(treesIndexed, 'trees-indexed fired')
151 t.ok(treesIndexed.count > 0, 'non-zero tree count')
152 t.is(treesIndexed.rootHash, server.manifest.rootTreeHash)
153
154 await server.close()
155 fs.rmSync(dir, { recursive: true, force: true })
156})
157
158test('DeployServer — empty directory produces a valid rootTreeHash', async (t) => {
159 const dir = mktemp('empty')
160 const server = new DeployServer(dir)
161 await server.ready()
162
163 t.ok(server.manifest.rootTreeHash, 'empty dir has a tree hash')
164 t.is(server.manifest.files.length, 0)
165
166 const root = await server._fileIndex.getTree(server.manifest.rootTreeHash)
167 t.ok(root)
168 t.is(root.entries.length, 0)
169
170 await server.close()
171 fs.rmSync(dir, { recursive: true, force: true })
172})