| … | … | @@ -19,7 +19,7 @@ |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | 21 | import { Room, createRoomPairing, joinRoomPairing, resumeRoomPairing, decodeRoomPairingInvite } from 'hyper-rooms' |
| 22 | import { issuesSchema, DEFAULT_LABELS } from './schema.js' | |
| 22 | import { issuesSchema, DEFAULT_LABELS, ZERO_OID } from './schema.js' | |
| 23 | 23 | |
| 24 | 24 | export class HyperIssues { |
| 25 | 25 | /** |
| … | … | @@ -510,6 +510,34 @@ export class HyperIssues { |
| 510 | 510 | return refs |
| 511 | 511 | } |
| 512 | 512 | |
| 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 | ||
| 513 | 541 | // ── Meta ──────────────────────────────────────────────────── |
| 514 | 542 | |
| 515 | 543 | async setMeta (key, value) { |
| … | … | @@ -5,7 +5,8 @@ |
| 5 | 5 | * create-issue, update-issue |
| 6 | 6 | * create-comment, update-comment, delete-comment |
| 7 | 7 | * 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) | |
| 9 | 10 | * set-meta |
| 10 | 11 | * |
| 11 | 12 | * View schema (Hyperbee keys): |
| … | … | @@ -13,6 +14,7 @@ |
| 13 | 14 | * comment:{issueId}:{id} → { body, author, createdAt, editedAt?, editedBy? } |
| 14 | 15 | * label:{name} → { color, description, displayName } |
| 15 | 16 | * ref:{refPath} → { hash, updatedBy, timestamp } |
| 17 | * bundle:{oid} → { baseOid, contentHash, size, ref, addedBy, timestamp } | |
| 16 | 18 | * meta:{key} → { value } |
| 17 | 19 | * counter:issues → { next: number } |
| 18 | 20 | */ |
| … | … | @@ -67,6 +69,10 @@ export const issuesSchema = jsonSchema({ |
| 67 | 69 | if (!op.ref) throw new Error('delete-ref requires ref') |
| 68 | 70 | if (op.oldOid != null && typeof op.oldOid !== 'string') throw new Error('delete-ref oldOid must be a string') |
| 69 | 71 | 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 | |
| 70 | 76 | case 'set-meta': |
| 71 | 77 | if (!op.key) throw new Error('set-meta requires key') |
| 72 | 78 | break |
| … | … | @@ -208,6 +214,23 @@ export const issuesSchema = jsonSchema({ |
| 208 | 214 | break |
| 209 | 215 | } |
| 210 | 216 | |
| 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 | ||
| 211 | 234 | case 'set-meta': { |
| 212 | 235 | await batch.put(`meta:${op.key}`, { value: op.value }) |
| 213 | 236 | break |
| … | … | @@ -243,6 +243,42 @@ test('refs FF: delete honours oldOid (stale delete rejected, matching accepted)' |
| 243 | 243 | t.is(await tracker.getRef('refs/heads/main'), null, 'matching delete removed ref') |
| 244 | 244 | }) |
| 245 | 245 | |
| 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 | ||
| 251 | test('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 | ||
| 263 | test('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 | ||
| 273 | test('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 | ||
| 246 | 282 | test('meta key-value storage', async (t) => { |
| 247 | 283 | const tracker = await makeTracker(t, 'test-9') |
| 248 | 284 |