OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-issues commits / 292350f

P3b: bundle chain bookkeeping in the issue autobase
gip-overlay P3 — the fetcher needs to know which delta bundle(s) cover a target oid. Record them in the SAME per-repo autobase as the refs so they replicate together (multi-writer, signed). - add-bundle op → view key bundle:<oid> = { baseOid, contentHash, size, ref, addedBy, timestamp }. Idempotent (first writer wins; replay-safe). baseOid defaults to ZERO_OID for a creation bundle. - HyperIssues.addBundle(info, userId) / getBundle(oid). - A fetcher walks oid -> baseOid back to genesis to resolve the chain. 3 tests: add/retrieve, missing→null + ZERO default, idempotency. Suite 30/30, 99/99 asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
292350f7ff49b039ab8a838794becead7851b9fd
by plankatron <fleeky@gmail.com> · 2026-05-29T16:23:41.000Z

Changed files (3) +89 -2

lib/hyper-issues.js +29 -1
……@@ -19,7 +19,7 @@
1919 */
2020
2121import { Room, createRoomPairing, joinRoomPairing, resumeRoomPairing, decodeRoomPairingInvite } from 'hyper-rooms'
22import { issuesSchema, DEFAULT_LABELS } from './schema.js'
22import { issuesSchema, DEFAULT_LABELS, ZERO_OID } from './schema.js'
2323
2424export class HyperIssues {
2525 /**
……@@ -510,6 +510,34 @@ export class HyperIssues {
510510 return refs
511511 }
512512
513 // ── Bundle chain (gip-overlay object transport) ─────────────
514 //
515 // Each push records the overlay content hash of its delta bundle, keyed by
516 // the tip oid it produces, linked to the baseOid it builds on. A fetcher
517 // walks oid -> baseOid back to genesis (ZERO_OID) to resolve which bundles
518 // to pull. Idempotent: the first writer to record a given oid wins.
519
520 async addBundle (info, userId) {
521 if (!info || !info.oid) throw new Error('addBundle requires { oid }')
522 if (!info.contentHash) throw new Error('addBundle requires { contentHash }')
523 await this._room.append({
524 type: 'add-bundle',
525 oid: info.oid,
526 baseOid: info.baseOid || ZERO_OID,
527 contentHash: info.contentHash,
528 size: info.size || 0,
529 ref: info.ref || null,
530 userId,
531 timestamp: Date.now()
532 })
533 }
534
535 async getBundle (oid) {
536 await this._room.base.update()
537 const entry = await this._room.view.get(`bundle:${oid}`)
538 return entry ? entry.value : null
539 }
540
513541 // ── Meta ────────────────────────────────────────────────────
514542
515543 async setMeta (key, value) {
lib/schema.js +24 -1
……@@ -5,7 +5,8 @@
55 * create-issue, update-issue
66 * create-comment, update-comment, delete-comment
77 * create-label, update-label, delete-label
8 * update-ref, delete-ref (optional, for git integration)
8 * update-ref, delete-ref (optional, for git integration; FF-enforced)
9 * add-bundle (optional, gip-overlay bundle chain)
910 * set-meta
1011 *
1112 * View schema (Hyperbee keys):
……@@ -13,6 +14,7 @@
1314 * comment:{issueId}:{id} → { body, author, createdAt, editedAt?, editedBy? }
1415 * label:{name} → { color, description, displayName }
1516 * ref:{refPath} → { hash, updatedBy, timestamp }
17 * bundle:{oid} → { baseOid, contentHash, size, ref, addedBy, timestamp }
1618 * meta:{key} → { value }
1719 * counter:issues → { next: number }
1820 */
……@@ -67,6 +69,10 @@ export const issuesSchema = jsonSchema({
6769 if (!op.ref) throw new Error('delete-ref requires ref')
6870 if (op.oldOid != null && typeof op.oldOid !== 'string') throw new Error('delete-ref oldOid must be a string')
6971 break
72 case 'add-bundle':
73 if (!op.oid) throw new Error('add-bundle requires oid')
74 if (!op.contentHash) throw new Error('add-bundle requires contentHash')
75 break
7076 case 'set-meta':
7177 if (!op.key) throw new Error('set-meta requires key')
7278 break
……@@ -208,6 +214,23 @@ export const issuesSchema = jsonSchema({
208214 break
209215 }
210216
217 case 'add-bundle': {
218 // Bundle chain entry, keyed by the tip oid it produces. Idempotent —
219 // first writer wins (replay-safe; concurrent writers converge).
220 const key = `bundle:${op.oid}`
221 const existing = await batch.get(key)
222 if (existing) break
223 await batch.put(key, {
224 baseOid: op.baseOid || ZERO_OID,
225 contentHash: op.contentHash,
226 size: op.size || 0,
227 ref: op.ref || null,
228 addedBy: op.userId,
229 timestamp: op.timestamp || Date.now()
230 })
231 break
232 }
233
211234 case 'set-meta': {
212235 await batch.put(`meta:${op.key}`, { value: op.value })
213236 break
test/basic.test.js +36 -0
……@@ -243,6 +243,42 @@ test('refs FF: delete honours oldOid (stale delete rejected, matching accepted)'
243243 t.is(await tracker.getRef('refs/heads/main'), null, 'matching delete removed ref')
244244})
245245
246// ── gip-overlay P3: bundle chain bookkeeping ────────────────────
247// Each push records the overlay content hash of its delta bundle, keyed by
248// the tip oid it produces, with the baseOid it builds on. A fetcher walks
249// oid -> baseOid back to genesis (ZERO) to resolve which bundles to pull.
250
251test('bundles: add and retrieve a bundle entry', async (t) => {
252 const tracker = await makeTracker(t, 'test-bundle-1')
253
254 await tracker.addBundle({ oid: 'o2', baseOid: 'o1', contentHash: 'hashB', size: 123, ref: 'refs/heads/main' }, 'user-1')
255 const b = await tracker.getBundle('o2')
256 t.is(b.baseOid, 'o1')
257 t.is(b.contentHash, 'hashB')
258 t.is(b.size, 123)
259 t.is(b.ref, 'refs/heads/main')
260 t.is(b.addedBy, 'user-1')
261})
262
263test('bundles: missing oid resolves to null; creation bundle defaults baseOid to ZERO', async (t) => {
264 const tracker = await makeTracker(t, 'test-bundle-2')
265
266 t.is(await tracker.getBundle('nope'), null, 'unknown oid → null')
267
268 await tracker.addBundle({ oid: 'genesis', contentHash: 'hashA', size: 9 }, 'user-1') // no baseOid
269 const b = await tracker.getBundle('genesis')
270 t.is(b.baseOid, ZERO_OID, 'creation bundle baseOid defaults to ZERO_OID')
271})
272
273test('bundles: add-bundle is idempotent (first writer wins)', async (t) => {
274 const tracker = await makeTracker(t, 'test-bundle-3')
275
276 await tracker.addBundle({ oid: 'o2', baseOid: 'o1', contentHash: 'first', size: 1 }, 'user-1')
277 await tracker.addBundle({ oid: 'o2', baseOid: 'o1', contentHash: 'second', size: 2 }, 'user-2')
278 const b = await tracker.getBundle('o2')
279 t.is(b.contentHash, 'first', 'replay/duplicate does not overwrite')
280})
281
246282test('meta key-value storage', async (t) => {
247283 const tracker = await makeTracker(t, 'test-9')
248284