| … | … | @@ -3,7 +3,7 @@ |
| 3 | 3 | */ |
| 4 | 4 | |
| 5 | 5 | export { HyperIssues, joinRepoPairing } from './lib/hyper-issues.js' |
| 6 | export { issuesSchema, DEFAULT_LABELS } from './lib/schema.js' | |
| 6 | export { issuesSchema, DEFAULT_LABELS, ZERO_OID } from './lib/schema.js' | |
| 7 | 7 | // Re-export the shared blind-pairing primitive so the daemon can mint/decode |
| 8 | 8 | // repo invites without importing hyper-rooms directly. (The bearer-invite |
| 9 | 9 | // encode/decode is retired — repo join is blind-pairing now.) |
| … | … | @@ -482,12 +482,15 @@ export class HyperIssues { |
| 482 | 482 | |
| 483 | 483 | // ── Refs (optional, for git integration) ──────────────────── |
| 484 | 484 | |
| 485 | async updateRef (ref, hash, userId) { | |
| 486 | await this._room.append({ type: 'update-ref', ref, hash, userId, timestamp: Date.now() }) | |
| 485 | // oldOid is the value the writer observed for `ref` (git's <oldrev>): | |
| 486 | // omit / null / zero-oid to create a ref that must not already exist. | |
| 487 | // The apply rejects the op if the view no longer matches oldOid (non-FF). | |
| 488 | async updateRef (ref, hash, userId, oldOid = null) { | |
| 489 | await this._room.append({ type: 'update-ref', ref, hash, userId, oldOid, timestamp: Date.now() }) | |
| 487 | 490 | } |
| 488 | 491 | |
| 489 | async deleteRef (ref, userId) { | |
| 490 | await this._room.append({ type: 'delete-ref', ref, userId, timestamp: Date.now() }) | |
| 492 | async deleteRef (ref, userId, oldOid = null) { | |
| 493 | await this._room.append({ type: 'delete-ref', ref, userId, oldOid, timestamp: Date.now() }) | |
| 491 | 494 | } |
| 492 | 495 | |
| 493 | 496 | async getRef (ref) { |
| … | … | @@ -19,6 +19,17 @@ |
| 19 | 19 | |
| 20 | 20 | import { jsonSchema } from 'hyper-rooms' |
| 21 | 21 | |
| 22 | // git's all-zero oid — the "ref does not exist" sentinel that | |
| 23 | // git-receive-pack passes as <oldrev> on ref creation. | |
| 24 | export const ZERO_OID = '0000000000000000000000000000000000000000' | |
| 25 | ||
| 26 | // Normalize a declared previous-oid to either a concrete oid or null | |
| 27 | // (meaning "the ref must be absent"). Absent ⇔ omitted / falsy / zero-oid. | |
| 28 | function normalizeOldOid (oldOid) { | |
| 29 | if (!oldOid || oldOid === ZERO_OID) return null | |
| 30 | return oldOid | |
| 31 | } | |
| 32 | ||
| 22 | 33 | export const issuesSchema = jsonSchema({ |
| 23 | 34 | validate (op) { |
| 24 | 35 | switch (op.type) { |
| … | … | @@ -50,9 +61,11 @@ export const issuesSchema = jsonSchema({ |
| 50 | 61 | case 'update-ref': |
| 51 | 62 | if (!op.ref) throw new Error('update-ref requires ref') |
| 52 | 63 | if (!op.hash) throw new Error('update-ref requires hash') |
| 64 | if (op.oldOid != null && typeof op.oldOid !== 'string') throw new Error('update-ref oldOid must be a string') | |
| 53 | 65 | break |
| 54 | 66 | case 'delete-ref': |
| 55 | 67 | if (!op.ref) throw new Error('delete-ref requires ref') |
| 68 | if (op.oldOid != null && typeof op.oldOid !== 'string') throw new Error('delete-ref oldOid must be a string') | |
| 56 | 69 | break |
| 57 | 70 | case 'set-meta': |
| 58 | 71 | if (!op.key) throw new Error('set-meta requires key') |
| … | … | @@ -167,7 +180,14 @@ export const issuesSchema = jsonSchema({ |
| 167 | 180 | } |
| 168 | 181 | |
| 169 | 182 | case 'update-ref': { |
| 170 | await batch.put(`ref:${op.ref}`, { | |
| 183 | const key = `ref:${op.ref}` | |
| 184 | const existing = await batch.get(key) | |
| 185 | const currentOid = existing ? existing.value.hash : null | |
| 186 | // Fast-forward / compare-and-swap guard: the op declares the oid it | |
| 187 | // observed (oldOid). If the view has moved since, reject (non-FF) and | |
| 188 | // leave the ref unchanged. Absent ref ⇔ oldOid null/zero-oid. | |
| 189 | if (currentOid !== normalizeOldOid(op.oldOid)) break | |
| 190 | await batch.put(key, { | |
| 171 | 191 | hash: op.hash, |
| 172 | 192 | updatedBy: op.userId, |
| 173 | 193 | timestamp: op.timestamp || Date.now() |
| … | … | @@ -176,7 +196,15 @@ export const issuesSchema = jsonSchema({ |
| 176 | 196 | } |
| 177 | 197 | |
| 178 | 198 | case 'delete-ref': { |
| 179 | await batch.del(`ref:${op.ref}`) | |
| 199 | const key = `ref:${op.ref}` | |
| 200 | // If oldOid is declared, guard the delete the same way; an omitted | |
| 201 | // oldOid is an unconditional delete (back-compat). | |
| 202 | if (op.oldOid != null) { | |
| 203 | const existing = await batch.get(key) | |
| 204 | const currentOid = existing ? existing.value.hash : null | |
| 205 | if (currentOid !== normalizeOldOid(op.oldOid)) break | |
| 206 | } | |
| 207 | await batch.del(key) | |
| 180 | 208 | break |
| 181 | 209 | } |
| 182 | 210 |
| … | … | @@ -4,7 +4,7 @@ import { mkdtempSync } from 'fs' |
| 4 | 4 | import { join } from 'path' |
| 5 | 5 | import { tmpdir } from 'os' |
| 6 | 6 | import { replicateAndSync } from 'autobase-test-helpers' |
| 7 | import { HyperIssues } from '../index.js' | |
| 7 | import { HyperIssues, ZERO_OID } from '../index.js' | |
| 8 | 8 | |
| 9 | 9 | function tmpStore () { |
| 10 | 10 | const dir = mkdtempSync(join(tmpdir(), 'hyper-issues-test-')) |
| … | … | @@ -174,6 +174,75 @@ test('refs (optional git feature)', async (t) => { |
| 174 | 174 | t.is(after.length, 1) |
| 175 | 175 | }) |
| 176 | 176 | |
| 177 | // ── gip-overlay P1: fast-forward (compare-and-swap) refs ───────── | |
| 178 | // The apply rejects a ref move whose declared prior oid (oldOid) does not | |
| 179 | // match the current view value. Reachability-FF stays enforced locally by | |
| 180 | // git-receive-pack; this guard is the cross-writer concurrency check. | |
| 181 | ||
| 182 | test('refs FF: fast-forward update accepted', async (t) => { | |
| 183 | const tracker = await makeTracker(t, 'test-ref-ff-1') | |
| 184 | ||
| 185 | // create — no prior value (oldOid omitted == absent) | |
| 186 | await tracker.updateRef('refs/heads/main', 'aaa', 'user-1') | |
| 187 | t.is((await tracker.getRef('refs/heads/main')).hash, 'aaa') | |
| 188 | ||
| 189 | // fast-forward: declare the observed oid, move it forward | |
| 190 | await tracker.updateRef('refs/heads/main', 'bbb', 'user-1', 'aaa') | |
| 191 | t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb', 'ref moved aaa -> bbb') | |
| 192 | }) | |
| 193 | ||
| 194 | test('refs FF: non-fast-forward (stale oldOid) rejected', async (t) => { | |
| 195 | const tracker = await makeTracker(t, 'test-ref-ff-2') | |
| 196 | ||
| 197 | await tracker.updateRef('refs/heads/main', 'aaa', 'user-1') // create | |
| 198 | await tracker.updateRef('refs/heads/main', 'bbb', 'user-1', 'aaa') // ff aaa -> bbb | |
| 199 | t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb') | |
| 200 | ||
| 201 | // stale: writer still believes ref is at 'aaa' — must be rejected | |
| 202 | await tracker.updateRef('refs/heads/main', 'ccc', 'user-1', 'aaa') | |
| 203 | t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb', 'stale update rejected, ref unchanged') | |
| 204 | }) | |
| 205 | ||
| 206 | test('refs FF: create with non-zero oldOid rejected; zero-oid create accepted', async (t) => { | |
| 207 | const tracker = await makeTracker(t, 'test-ref-ff-3') | |
| 208 | ||
| 209 | // claims a prior value but ref is absent → reject | |
| 210 | await tracker.updateRef('refs/heads/new', 'xxx', 'user-1', 'deadbeef') | |
| 211 | t.is(await tracker.getRef('refs/heads/new'), null, 'phantom-prior create rejected') | |
| 212 | ||
| 213 | // zero-oid explicitly means "must not exist" → accepted | |
| 214 | await tracker.updateRef('refs/heads/new', 'xxx', 'user-1', ZERO_OID) | |
| 215 | t.is((await tracker.getRef('refs/heads/new')).hash, 'xxx', 'zero-oid create accepted') | |
| 216 | }) | |
| 217 | ||
| 218 | test('refs FF: linearized competing updates — first wins, second rejected', async (t) => { | |
| 219 | const tracker = await makeTracker(t, 'test-ref-ff-4') | |
| 220 | ||
| 221 | await tracker.updateRef('refs/heads/main', 'base', 'user-1') // create | |
| 222 | ||
| 223 | // both updates branched from 'base'. After linearization the first | |
| 224 | // applied wins; the second (still citing 'base') is non-FF and dropped. | |
| 225 | await tracker.updateRef('refs/heads/main', 'win', 'user-1', 'base') | |
| 226 | await tracker.updateRef('refs/heads/main', 'lose', 'user-2', 'base') | |
| 227 | ||
| 228 | t.is((await tracker.getRef('refs/heads/main')).hash, 'win', 'first FF wins, second rejected') | |
| 229 | }) | |
| 230 | ||
| 231 | test('refs FF: delete honours oldOid (stale delete rejected, matching accepted)', async (t) => { | |
| 232 | const tracker = await makeTracker(t, 'test-ref-ff-5') | |
| 233 | ||
| 234 | await tracker.updateRef('refs/heads/main', 'aaa', 'user-1') | |
| 235 | await tracker.updateRef('refs/heads/main', 'bbb', 'user-1', 'aaa') | |
| 236 | ||
| 237 | // stale delete (cites the old value) → rejected | |
| 238 | await tracker.deleteRef('refs/heads/main', 'user-1', 'aaa') | |
| 239 | t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb', 'stale delete rejected') | |
| 240 | ||
| 241 | // matching delete → accepted | |
| 242 | await tracker.deleteRef('refs/heads/main', 'user-1', 'bbb') | |
| 243 | t.is(await tracker.getRef('refs/heads/main'), null, 'matching delete removed ref') | |
| 244 | }) | |
| 245 | ||
| 177 | 246 | test('meta key-value storage', async (t) => { |
| 178 | 247 | const tracker = await makeTracker(t, 'test-9') |
| 179 | 248 |