clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

grasp: checkpoint-aware ref advertisements preserve authority, indexed correction retries survive later commits, and retained metadata stays reserved

Dami committed fe21b98
commit
fe21b98becb1c55df48758e819b069cddf510b2f
parent
3cd919734d049c9891457e7c2a4584d7163afa16
author
Dami <dami.etoile@gmail.com>
6 files changed, +353 -14
Mdocs/15-costs-and-margins.md17++-
Mdocs/22-grasp-01-git-hosting.md22++-
Msrc/grasp.ts1+
Msrc/nip11.ts2+-
Atest/object/grasp-checkpoint.test.ts208++++++++++++++++++++++++++++++
Mtest/object/grasp.test.ts117++++++++++++++++-
M docs/15-costs-and-margins.md +15 -2
@@ -103,7 +103,8 @@ Prices go up when bitcoin falls. When it rises, do nothing until a line has sat
103103 104104 ## Accounting coverage, checked Sept. 4, 2026105105 106-The following inventory describes the host with ntig 0.1.1. Observing an106+The following inventory describes the host with ntig 0.2.0 and unmigrated107+format-1 repositories. Installing the library does not migrate roots. Observing an107108 expense does not authorize a new customer charge. `/fuel` and the Health tab108109 expose aggregate tenant meters, not every operation the host pays for.109110
@@ -136,6 +137,18 @@ automatic reconciliation queue or orphan collector exists. Counting PUT
136137 bytes again as stored bytes would double-charge existing media accounting.137138 Future temporary packs also need reservations while both copies exist.138139 140+For an explicitly migrated format-2 repository, ref advertisements read two141+R2 objects, the root and manifest. This saves legacy history and pack reads142+on that path; full loads and Git transfers still read all stored packs.143+Each new commit also retains a manifest and receipt-index path nodes beside144+its record and optional pack. Ref-only workloads can pass 128 transactions,145+but their metadata and reservation rows continue to grow. All of these keys146+use the same pre-PUT storage reservation. CAS-loser objects remain accounted147+for, and an ambiguous PUT can leave a reservation larger than physical148+storage. Neither receipt lookup nor checkpointing reclaims old bytes.149+Operation counts are host costs, not a new tenant charge; lower read costs150+alone do not establish a margin after metadata writes and retained storage.151+139152 `graspBusy`, `graspControls` and the job round's `working` flag fence work140153 within a live instance. Async handlers can interleave across awaits. Those141154 flags are admission controls, not persisted crash recovery or authority to
@@ -219,7 +232,7 @@ These are proposed operating boundaries, not new allowances or charges.
219232 220233 ## Proposed maintenance contract221234 222-ntig 0.1.1 has no resumable optimization runner. No maintenance job, budget235+ntig 0.2.0 has no resumable optimization runner. No maintenance job, budget223236 setting, wake schedule, format migration or garbage collection is enabled by224237 this contract. Existing ref cleanup, retention and alarms keep their current225238 roles. An eventual integration has these boundaries:
M docs/22-grasp-01-git-hosting.md +17 -5
@@ -121,11 +121,23 @@ The hard limits are:
121121 |---|---:|---|122122 | Relay | 16 repositories, 320 MiB Git storage | accepted repositories and billed retained Git objects across the relay |123123 | Repository | 4 MiB per pack, 16 MiB packed history | one upload and the aggregate packed history read into memory |124-| Repository | 128 transactions, 1,024 refs, 4,096 objects | WAL history, ref maps and verified object graphs |125-126-Checkpoints, compaction, orphan collection, large-pack streaming and127-production capacity measurements remain follow-up work. Once a repository128-reaches its transaction limit, all further Git writes are refused, including124+| Repository | 1,024 refs, 4,096 objects | ref maps and verified object graphs |125+| Format 1 | 128 transactions | legacy WAL replay |126+| Format 2 | 128 unique packs, 2 MiB manifest | explicitly migrated repository metadata |127+128+ntig 0.2.0 reads both formats, but installing it leaves existing roots alone129+and new repositories still use format 1. Bindws exposes no checkpoint action130+or scheduled migration. The backend's explicit `checkpoint()` upgrade is131+one-way: every reader and writer must support format 2 before an isolated132+repository is migrated. Versions 0.1.x cannot read the upgraded root.133+Migration retains old data and receipts; it does not reclaim storage.134+135+Format-2 ref advertisements read the root and manifest without downloading136+packs. They retain the same accepted HEAD and hidden PR rules. Full clone,137+fetch and push still load the stored packs; selective object reads,138+compaction, orphan collection, large-pack streaming and production capacity139+measurements remain follow-up work. Once a format-1 repository reaches its140+transaction limit, all further Git writes are refused, including129141 the timed cleanup transaction. An expired unknown PR ref therefore stays130142 hidden when cleanup cannot commit, while immutable WAL history keeps its old131143 object bytes billed until the relay is torn down. The same retention behavior
M src/grasp.ts +1 -0
M src/nip11.ts +1 -1
A test/object/grasp-checkpoint.test.ts +208 -0
@@ -0,0 +1,208 @@
1+// Checkpoint migration is exercised through bindws' repository store. The2+// fixture is isolated to one Durable Object and never touches a production key.3+import { env, runInDurableObject } from "cloudflare:test";4+import { describe, expect, it } from "vitest";5+import { generateSecretKey } from "nostr-tools/pure";6+import { npubEncode } from "nostr-tools/nip19";7+import { encodePack, type WalRecord } from "ntig";8+import { KIND_REPO } from "../../src/kinds.ts";9+import { repository } from "../../src/grasp-state.ts";10+import { gitRepository } from "../../src/grasp.ts";11+import type { RepositoryAnnouncement } from "../../src/grasp-policy.ts";12+import { ev, pk, rpc } from "../helpers/relay.ts";13+import { WS } from "../helpers/ws.ts";14+15+const encoder = new TextEncoder();16+const oid = async (type: string, data: Uint8Array) => {17+ const header = encoder.encode(`${type} ${data.length}\0`);18+ const input = new Uint8Array(header.length + data.length);19+ input.set(header); input.set(data, header.length);20+ return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-1", input)), (b) => b.toString(16).padStart(2, "0")).join("");21+};22+23+async function fixturePack() {24+ const blob = encoder.encode("checkpoint fixture\n");25+ const blobID = await oid("blob", blob);26+ const tree = new Uint8Array([...encoder.encode("100644 README\0"), ...Uint8Array.from(blobID.match(/../g)!, (part) => parseInt(part, 16))]);27+ const treeID = await oid("tree", tree);28+ const commit = encoder.encode(`tree ${treeID}\nauthor ntig <ntig@example.com> 1 +0000\ncommitter ntig <ntig@example.com> 1 +0000\n\ncheckpoint\n`);29+ const commitID = await oid("commit", commit);30+ return { commitID, pack: await encodePack([{ type: "blob", data: blob }, { type: "tree", data: tree }, { type: "commit", data: commit }]) };31+}32+33+const announcement = (sk: Uint8Array, host: string, identifier: string) => ev(sk, KIND_REPO, "", [34+ ["d", identifier],35+ ["clone", `https://${host}/${npubEncode(pk(sk))}/${identifier}.git`],36+ ["relays", `wss://${host}`],37+ ["maintainers", pk(sk)],38+]);39+40+describe("GRASP checkpoint integration", () => {41+ it("migrates an isolated legacy repository and retains indexed receipts", async () => {42+ const host = "grasp-checkpoint.bind.ws";43+ const owner = generateSecretKey();44+ const identifier = "checkpoint";45+ await rpc(host, owner, "claim");46+ await rpc(host, owner, "setpolicy", { features: { grasp: true } });47+ const connection = await WS.connect(host);48+ const event = announcement(owner, host, identifier);49+ expect((await connection.ok(event)).ok).toBe(true);50+ connection.ws.close();51+52+ const fixture = await fixturePack();53+ const measurements = await runInDurableObject(env.RELAY.getByName(host.split(".")[0]), async (relay) => {54+ const repo = repository(relay, pk(owner), identifier) as RepositoryAnnouncement | null;55+ expect(repo).not.toBeNull();56+ const wal = await gitRepository(relay, repo!);57+ const first = await wal.commit({ id: "checkpoint-initial", updates: [{ name: "refs/heads/main", old: null, new: fixture.commitID }], pack: fixture.pack });58+ const legacy: WalRecord[] = [];59+ legacy.push((await wal.lookupRecord!(first.id))!);60+ let prior = fixture.commitID;61+ for (let i = 1; i < 128; i++) {62+ const receipt = await wal.commit({ id: `checkpoint-legacy-${i}`, updates: [{ name: `refs/tags/legacy-${i}`, old: null, new: prior }] });63+ legacy.push((await wal.lookupRecord!(receipt.id))!);64+ }65+ expect((await wal.load()).sequence).toBe(128);66+ const before = relay.sql.exec<{ n: number; bytes: number }>("SELECT count(*) AS n, coalesce(sum(size),0) AS bytes FROM grasp_objects WHERE owner=?", pk(owner)).one();67+68+ const migration = await wal.checkpoint();69+ expect(migration).toEqual({ sequence: 128, changed: true });70+ expect(await wal.checkpoint()).toEqual({ sequence: 128, changed: false });71+ const migrated = await wal.load();72+ expect(migrated.checkpoint?.manifestHash).toMatch(/^[0-9a-f]{64}$/);73+ expect(migrated.records).toHaveLength(1);74+ expect(migrated.refs["refs/heads/main"]).toBe(fixture.commitID);75+ expect(await wal.lookupRecord!(legacy[0]!.id)).toMatchObject({ id: legacy[0]!.id, sequence: 1 });76+77+ // Format 2 allows more than the legacy 128-record ceiling. Ref-only78+ // commits exercise manifest/index growth without creating more packs.79+ for (let i = 128; i < 260; i++) {80+ await wal.commit({ id: `checkpoint-v2-${i}`, updates: [{ name: `refs/tags/v2-${i}`, old: null, new: prior }] });81+ }82+ const replay = await wal.commit({ id: first.id, updates: [{ name: "refs/heads/main", old: null, new: fixture.commitID }], pack: fixture.pack });83+ expect(replay).toMatchObject({ id: first.id, sequence: 1, replayed: true });84+ const rebuilt = await gitRepository(relay, repo!);85+ const cold = await rebuilt.load();86+ expect(cold.sequence).toBe(260);87+ expect(cold.refs["refs/tags/v2-259"]).toBe(fixture.commitID);88+ const advertised = await rebuilt.loadRefs!();89+ expect(advertised.sequence).toBe(260);90+ expect(advertised.refs["refs/heads/main"]).toBe(fixture.commitID);91+ expect(await rebuilt.lookupRecord!("checkpoint-legacy-127")).toMatchObject({ sequence: 128 });92+ expect(await rebuilt.lookupRecord!("missing-checkpoint-receipt")).toBeNull();93+94+ const after = relay.sql.exec<{ n: number; bytes: number }>("SELECT count(*) AS n, coalesce(sum(size),0) AS bytes FROM grasp_objects WHERE owner=?", pk(owner)).one();95+ const keys = relay.sql.exec<{ key: string; size: number }>("SELECT key, size FROM grasp_objects WHERE owner=? ORDER BY key", pk(owner)).toArray();96+ const categories = keys.reduce<Record<string, number>>((counts, { key }) => {97+ const category = key.split("/").at(-2) ?? key;98+ counts[category] = (counts[category] ?? 0) + 1;99+ return counts;100+ }, {});101+ return { before, after, keyCount: keys.length, categories, sequence: cold.sequence };102+ });103+104+ console.info("checkpoint storage measurement", {105+ beforeObjects: measurements.before.n,106+ afterObjects: measurements.after.n,107+ beforeBytes: measurements.before.bytes,108+ afterBytes: measurements.after.bytes,109+ retainedKeys: measurements.keyCount,110+ sequence: measurements.sequence,111+ });112+ // The accounting adapter retains every immutable manifest/index/receipt113+ // write. This is intentionally observational: no successful PUT sum is114+ // treated as net storage, and the test records the retained SQL totals.115+ expect(measurements.after.n).toBeGreaterThan(measurements.before.n);116+ expect(measurements.after.bytes).toBeGreaterThan(measurements.before.bytes);117+ expect(measurements.keyCount).toBeGreaterThan(128);118+ expect(measurements.sequence).toBe(260);119+ });120+121+ it("retains a reservation when an immutable checkpoint write is ambiguous", async () => {122+ const host = "grasp-checkpoint-ambiguous.bind.ws";123+ const owner = generateSecretKey();124+ await rpc(host, owner, "claim");125+ const fixture = await fixturePack();126+ await runInDurableObject(env.RELAY.getByName(host.split(".")[0]), async (relay) => {127+ const objects = new Map<string, { bytes: Uint8Array; etag: string }>();128+ const operations = { get: 0, getBytes: 0, put: 0, putBytes: 0, putAck: 0, head: 0, headBytes: 0 };129+ let writes = 0;130+ let throwManifest = false;131+ let failedManifestKey = "";132+ let failedManifestBytes = 0;133+ const previous = { ...operations };134+ const measure = (phase: string) => {135+ const delta = Object.fromEntries(Object.keys(operations).map((key) => [key, operations[key as keyof typeof operations] - previous[key as keyof typeof operations]]));136+ Object.assign(previous, operations);137+ console.info("checkpoint R2 operation measurement", { phase, ...delta });138+ };139+ const bucket = {140+ get: async (key: string) => {141+ operations.get++;142+ const item = objects.get(key);143+ if (item) operations.getBytes += item.bytes.length;144+ return item ? { size: item.bytes.length, etag: item.etag, arrayBuffer: async () => item.bytes.slice().buffer } : null;145+ },146+ put: async (key: string, value: ArrayBufferView, options: { onlyIf: { etagMatches?: string; etagDoesNotMatch?: string } }) => {147+ operations.put++;148+ operations.putBytes += value.byteLength;149+ const current = objects.get(key);150+ if (options.onlyIf.etagDoesNotMatch === "*" && current) return null;151+ if (options.onlyIf.etagMatches !== undefined && (!current || current.etag !== options.onlyIf.etagMatches)) return null;152+ const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice();153+ const item = { bytes, etag: `fake-${++writes}` };154+ objects.set(key, item);155+ if (throwManifest && key.includes("/manifests/")) {156+ failedManifestKey = key;157+ failedManifestBytes = bytes.length;158+ throw new Error("lost manifest acknowledgement");159+ }160+ operations.putAck++;161+ return { etag: item.etag };162+ },163+ head: async (key: string) => {164+ operations.head++;165+ const item = objects.get(key);166+ if (item) operations.headBytes += item.bytes.length;167+ return item ? { size: item.bytes.length } : null;168+ },169+ };170+ const fakeRelay = {171+ slug: "checkpoint-ambiguous",172+ media: bucket,173+ sql: relay.sql,174+ fuelStatus: () => ({ outOfFuel: false }),175+ settings: { limitsOf: () => ({ cap: 0 }) },176+ store: { authorBytes: () => 0 },177+ } as unknown as typeof relay;178+ const repo = { id: "a".repeat(64), owner: pk(owner), identifier: "ambiguous", clone: [], relays: [], maintainers: [pk(owner)], private: false };179+ const wal = await gitRepository(fakeRelay, repo);180+ await wal.commit({ id: "ambiguous-initial", updates: [{ name: "refs/heads/main", old: null, new: fixture.commitID }], pack: fixture.pack });181+ measure("legacy commit");182+ await wal.checkpoint();183+ measure("format1 to format2 checkpoint migration");184+ await wal.commit({ id: "measured-v2-ref", updates: [{ name: "refs/tags/measured", old: null, new: fixture.commitID }] });185+ measure("format2 ref-only commit");186+ await wal.loadRefs();187+ measure("format2 loadRefs");188+ await wal.load();189+ measure("format2 full load");190+ await wal.lookupRecord("ambiguous-initial");191+ measure("format2 old receipt lookup");192+ throwManifest = true;193+ await expect(wal.commit({ id: "ambiguous-next", updates: [{ name: "refs/tags/ambiguous", old: null, new: fixture.commitID }] })).rejects.toThrow();194+ measure("ambiguous manifest PUT");195+ expect(failedManifestKey).toContain("/manifests/");196+ const retained = relay.sql.exec<{ size: number }>("SELECT size FROM grasp_objects WHERE key=?", failedManifestKey).one();197+ expect(retained.size).toBe(failedManifestBytes);198+ expect((await wal.load()).refs["refs/tags/ambiguous"]).toBeUndefined();199+ throwManifest = false;200+ await expect(wal.commit({ id: "ambiguous-next", updates: [{ name: "refs/tags/ambiguous", old: null, new: fixture.commitID }] })).resolves.toMatchObject({ replayed: false });201+ expect((await wal.load()).refs["refs/tags/ambiguous"]).toBe(fixture.commitID);202+ const reserved = relay.sql.exec<{ n: number; bytes: number }>("SELECT count(*) AS n, coalesce(sum(size),0) AS bytes FROM grasp_objects WHERE owner=? AND key LIKE '%/manifests/%'", pk(owner)).one();203+ console.info("ambiguous checkpoint reservation", reserved);204+ expect(reserved.n).toBeGreaterThan(0);205+ expect(reserved.bytes).toBeGreaterThan(0);206+ });207+ });208+});
M test/object/grasp.test.ts +111 -6
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from "vitest";
55 import { generateSecretKey } from "nostr-tools/pure";66 import { npubEncode } from "nostr-tools/nip19";77 import { KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR, KIND_GIT_PR_UPDATE } from "../../src/kinds.ts";8+import { gitRepository } from "../../src/grasp.ts";9+import { repository as storedRepository } from "../../src/grasp-state.ts";810 import { gitRepositoryPath } from "../../src/grasp-policy.ts";911 import { ev, info, pk, rpc, sleep } from "../helpers/relay.ts";1012 import { WS } from "../helpers/ws.ts";
@@ -29,15 +31,15 @@ const objectID = async (type: string, data: Uint8Array) => {
2931 value.set(header); value.set(data, header.length);3032 return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-1", value)), (byte) => byte.toString(16).padStart(2, "0")).join("");3133 };32-const receivePack = (path: string, host: string, old: string | null, next: string | null, ref: string, pack?: Uint8Array) => SELF.fetch(`http://${host}${path}/git-receive-pack`, {34+const receivePack = (path: string, host: string, old: string | null, next: string | null, ref: string, pack?: Uint8Array, requestId?: string) => SELF.fetch(`http://${host}${path}/git-receive-pack`, {3335 method: "POST",34- headers: { "content-type": "application/x-git-receive-pack-request" },36+ headers: { "content-type": "application/x-git-receive-pack-request", ...(requestId ? { "X-Git-Request-Id": requestId } : {}) },3537 body: concat(packet(`${old ?? "0".repeat(40)} ${next ?? "0".repeat(40)} ${ref}\0report-status\n`), flush(), ...(pack ? [pack] : [])),3638 });3739 // receiveSettled waits for transient alarm contention before testing the Git response.38-const receiveSettled = async (path: string, host: string, old: string | null, next: string | null, ref: string, pack?: Uint8Array) => {40+const receiveSettled = async (path: string, host: string, old: string | null, next: string | null, ref: string, pack?: Uint8Array, requestId?: string) => {3941 for (let attempt = 0; attempt < 20; attempt++) {40- const response = await receivePack(path, host, old, next, ref, pack);42+ const response = await receivePack(path, host, old, next, ref, pack, requestId);4143 if (response.status !== 429) return response;4244 await response.arrayBuffer();4345 await new Promise((resolve) => setTimeout(resolve, 10));
@@ -45,8 +47,8 @@ const receiveSettled = async (path: string, host: string, old: string | null, ne
4547 throw new Error("Git transaction did not become available");4648 };4749 48-async function tinyRepository() {49- const blob = encoder.encode("hello from grasp\n");50+async function tinyRepository(content = "hello from grasp\n") {51+ const blob = encoder.encode(content);5052 const blobID = await objectID("blob", blob);5153 const tree = new Uint8Array([...encoder.encode("100644 README\0"), ...Uint8Array.from(blobID.match(/../g)!, (part) => parseInt(part, 16))]);5254 const treeID = await objectID("tree", tree);
@@ -250,6 +252,109 @@ describe("GRASP", () => {
250252 late.ws.close();251253 });252254 255+ it("replays a hidden PR correction after checkpointing and more than 128 later transactions", async () => {256+ const host = "grasp-pr-checkpoint.bind.ws";257+ const owner = generateSecretKey();258+ const first = await tinyRepository();259+ const next = await tinyRepository("corrected PR\n");260+ await rpc(host, owner, "claim");261+ await rpc(host, owner, "applypreset", "grasp");262+ const c = await WS.connect(host);263+ expect((await c.ok(repository(owner, host, "correction"))).ok).toBe(true);264+ expect((await c.ok(ev(owner, KIND_REPO_STATE, "", [["d", "correction"], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", first.commitID]]))).ok).toBe(true);265+ c.ws.close();266+ const path = gitRepositoryPath(npubEncode(pk(owner)), "correction");267+ expect(await (await receiveSettled(path, host, null, first.commitID, "refs/heads/main", first.pack)).text()).toContain("ok refs/heads/main");268+ const pr = ev(owner, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:correction`], ["c", next.commitID]]);269+ const ref = `refs/nostr/${pr.id}`;270+ expect(await (await receiveSettled(path, host, null, first.commitID, ref)).text()).toContain(`ok ${ref}`);271+ const publisher = await WS.connect(host);272+ expect((await publisher.ok(pr)).ok).toBe(true);273+ publisher.ws.close();274+ const hidden = await SELF.fetch(`http://${host}${path}/info/refs?service=git-upload-pack`);275+ expect(await hidden.text()).not.toContain(ref);276+277+ const stub = env.RELAY.getByName("grasp-pr-checkpoint");278+ await runInDurableObject(stub, async (relay) => {279+ relay.graspBusy = true;280+ try {281+ const wal = await gitRepository(relay, storedRepository(relay, pk(owner), "correction")!);282+ expect((await wal.checkpoint()).changed).toBe(true);283+ } finally { relay.graspBusy = false; }284+ });285+ const metadata = await runInDurableObject(stub, async (relay) => {286+ const bucket = relay.media;287+ const keys: string[] = [];288+ const observed = new Proxy(bucket, { get(target, name) {289+ if (name === "get") return (key: string, options?: R2GetOptions) => { keys.push(key); return target.get(key, options); };290+ const value = Reflect.get(target, name, target);291+ return typeof value === "function" ? value.bind(target) : value;292+ } });293+ Object.defineProperty(relay, "media", { value: observed, configurable: true });294+ try {295+ const response = await relay.fetch(new Request(`http://${host}${path}/info/refs?service=git-upload-pack`));296+ expect(response.status).toBe(200);297+ return { body: await response.text(), keys };298+ } finally { Reflect.deleteProperty(relay, "media"); }299+ });300+ expect(metadata.keys).toHaveLength(2);301+ expect(metadata.keys[0]).toMatch(/\/root.json$/);302+ expect(metadata.keys[1]).toContain("/manifests/");303+ expect(metadata.body).toContain("symref=HEAD:refs/heads/main");304+ expect(metadata.body).not.toContain(ref);305+ const requestId = "hidden-pr-correction";306+ expect(await (await receiveSettled(path, host, null, next.commitID, ref, next.pack, requestId)).text()).toContain(`ok ${ref}`);307+ const sequence = await runInDurableObject(stub, async (relay) => {308+ relay.graspBusy = true;309+ try {310+ const wal = await gitRepository(relay, storedRepository(relay, pk(owner), "correction")!);311+ for (let i = 0; i < 130; i++) await wal.commit({ id: `later-${i}`, updates: [{ name: `refs/tags/later-${i}`, old: null, new: first.commitID }] });312+ const snapshot = await wal.load();313+ expect(snapshot.records).toHaveLength(1);314+ expect(snapshot.records[0].id).not.toBe(requestId);315+ return snapshot.sequence;316+ } finally { relay.graspBusy = false; }317+ });318+ const retried = await runInDurableObject(stub, async (relay) => {319+ const bucket = relay.media;320+ const counters = { gets: 0, getBytes: 0, puts: 0, heads: 0 };321+ const observed = new Proxy(bucket, { get(target, name) {322+ if (name === "get") return async (key: string, options?: R2GetOptions) => {323+ counters.gets++;324+ const object = await target.get(key, options);325+ counters.getBytes += object?.size ?? 0;326+ return object;327+ };328+ if (name === "put" || name === "head") return (...args: unknown[]) => {329+ if (name === "put") counters.puts++; else counters.heads++;330+ return Reflect.apply(target[name], target, args);331+ };332+ const value = Reflect.get(target, name, target);333+ return typeof value === "function" ? value.bind(target) : value;334+ } });335+ Object.defineProperty(relay, "media", { value: observed, configurable: true });336+ try {337+ const response = await relay.fetch(new Request(`http://${host}${path}/git-receive-pack`, {338+ method: "POST",339+ headers: { "content-type": "application/x-git-receive-pack-request", "X-Git-Request-Id": requestId },340+ body: concat(packet(`${"0".repeat(40)} ${next.commitID} ${ref}\0report-status\n`), flush(), next.pack),341+ }));342+ return { body: await response.text(), counters };343+ } finally { Reflect.deleteProperty(relay, "media"); }344+ });345+ expect(retried.body).toContain(`ok ${ref}`);346+ expect(retried.counters.puts).toBe(0);347+ console.info("checkpoint old PR correction HTTP retry", JSON.stringify({ sequence, ...retried.counters }));348+ await runInDurableObject(stub, async (relay) => {349+ const wal = await gitRepository(relay, storedRepository(relay, pk(owner), "correction")!);350+ expect((await wal.load()).sequence).toBe(sequence);351+ });352+ const visible = await SELF.fetch(`http://${host}${path}/info/refs?service=git-upload-pack`);353+ const body = await visible.text();354+ expect(body).toContain(`${next.commitID} ${ref}`);355+ expect(body).toContain("symref=HEAD:refs/heads/main");356+ });357+253358 it("removes the public Git contract when reads become restricted", async () => {254359 const host = "grasp-restricted.bind.ws";255360 const owner = generateSecretKey();