OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-issues commits / d8782c5

hyper-issues: fast-forward (CAS) guard on update-ref/delete-ref
gip-overlay P1 — refs become the authority for git distribution. The update-ref apply now compares the op's declared prior oid (oldOid) against the current view value and rejects the move if they differ (non-FF), leaving the ref unchanged. Absent ref ⇔ oldOid null/zero-oid, matching git-receive-pack's <oldrev> creation convention. - schema: ZERO_OID + normalizeOldOid; FF guard in update-ref apply; optional oldOid guard on delete-ref (omitted = unconditional, back-compat). - updateRef/deleteRef gain an optional oldOid arg (default null). - export ZERO_OID. - tests: FF accepted, stale-oldOid rejected, phantom-prior create rejected, zero-oid create accepted, linearized competing updates (first wins), delete honours oldOid. Full suite 27/27, 91/91 asserts. Reachability-FF stays enforced locally by git-receive-pack; this guard is the cross-writer concurrency check at autobase apply time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
d8782c543d10fe4d0f9c6f088d9f26939647a7cc
by plankatron <fleeky@gmail.com> · 2026-05-29T15:54:35.000Z

Changed files (4) +108 -8

index.js +1 -1
……@@ -3,7 +3,7 @@
33 */
44
55export { HyperIssues, joinRepoPairing } from './lib/hyper-issues.js'
6export { issuesSchema, DEFAULT_LABELS } from './lib/schema.js'
6export { issuesSchema, DEFAULT_LABELS, ZERO_OID } from './lib/schema.js'
77// Re-export the shared blind-pairing primitive so the daemon can mint/decode
88// repo invites without importing hyper-rooms directly. (The bearer-invite
99// encode/decode is retired — repo join is blind-pairing now.)
lib/hyper-issues.js +7 -4
……@@ -482,12 +482,15 @@ export class HyperIssues {
482482
483483 // ── Refs (optional, for git integration) ────────────────────
484484
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() })
487490 }
488491
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() })
491494 }
492495
493496 async getRef (ref) {
lib/schema.js +30 -2
……@@ -19,6 +19,17 @@
1919
2020import { jsonSchema } from 'hyper-rooms'
2121
22// git's all-zero oid — the "ref does not exist" sentinel that
23// git-receive-pack passes as <oldrev> on ref creation.
24export 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.
28function normalizeOldOid (oldOid) {
29 if (!oldOid || oldOid === ZERO_OID) return null
30 return oldOid
31}
32
2233export const issuesSchema = jsonSchema({
2334 validate (op) {
2435 switch (op.type) {
……@@ -50,9 +61,11 @@ export const issuesSchema = jsonSchema({
5061 case 'update-ref':
5162 if (!op.ref) throw new Error('update-ref requires ref')
5263 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')
5365 break
5466 case 'delete-ref':
5567 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')
5669 break
5770 case 'set-meta':
5871 if (!op.key) throw new Error('set-meta requires key')
……@@ -167,7 +180,14 @@ export const issuesSchema = jsonSchema({
167180 }
168181
169182 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, {
171191 hash: op.hash,
172192 updatedBy: op.userId,
173193 timestamp: op.timestamp || Date.now()
……@@ -176,7 +196,15 @@ export const issuesSchema = jsonSchema({
176196 }
177197
178198 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)
180208 break
181209 }
182210
test/basic.test.js +70 -1
……@@ -4,7 +4,7 @@ import { mkdtempSync } from 'fs'
44import { join } from 'path'
55import { tmpdir } from 'os'
66import { replicateAndSync } from 'autobase-test-helpers'
7import { HyperIssues } from '../index.js'
7import { HyperIssues, ZERO_OID } from '../index.js'
88
99function tmpStore () {
1010 const dir = mkdtempSync(join(tmpdir(), 'hyper-issues-test-'))
……@@ -174,6 +174,75 @@ test('refs (optional git feature)', async (t) => {
174174 t.is(after.length, 1)
175175})
176176
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
182test('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
194test('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
206test('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
218test('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
231test('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
177246test('meta key-value storage', async (t) => {
178247 const tracker = await makeTracker(t, 'test-9')
179248