ReviewOS

also looking at this

pantry-pm/pantry

fix: hydrate durable registry snapshots before serving writes

#244
Merged chris wants to merge codex/wildloop-registry-rollout into main
11 files +202 -35

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/registry/src/object-snapshot.test.tsadded+83-0
Changes to packages/registry/src/object-snapshot.test.ts
@@ -0,0 +1,83 @@
1import type { S3Client } from './storage/aws-client'
2import { describe, expect, it } from 'bun:test'
3import { S3RequestError } from './storage/aws-client'
4import { ObjectSnapshot } from './storage/object-snapshot'
5import { ObjectMetadataStorage } from './storage/object-metadata'
6import { ObjectAnalytics } from './storage/object-analytics'
7
8function bucket(body: string, failure?: Error) {
9 let reads = 0
10 let writes = 0
11 const client = {
12 async getObjectBuffer() {
13 reads++
14 if (failure) throw failure
15 return Buffer.from(body)
16 },
17 async putObject() { writes++ },
18 } as unknown as S3Client
19 return { client, reads: () => reads, writes: () => writes }
20}
21
22describe('snapshot hydration safety', () => {
23 for (const failure of [
24 new S3RequestError('GET', 503, 'ServiceUnavailable'),
25 new S3RequestError('GET', 403, 'AccessDenied'),
26 new S3RequestError('GET', 404, 'NoSuchBucket'),
27 new Error('transport unavailable'),
28 new Error('adapter failed: 404 Not Found'),
29 ]) {
30 it(`rejects ${failure.message} without overwriting remote state`, async () => {
31 const remote = bucket('{"existing":true}', failure)
32 const snapshot = new ObjectSnapshot(remote.client, 'b', 'snapshot.json', () => ({}))
33 await expect(snapshot.load()).rejects.toBe(failure)
34 await expect(snapshot.flush()).rejects.toThrow()
35 expect(remote.writes()).toBe(0)
36 })
37 }
38
39 it('cannot flush before the initial read completes', async () => {
40 const remote = bucket('{}')
41 const snapshot = new ObjectSnapshot(remote.client, 'b', 'snapshot.json', () => ({}))
42 await expect(snapshot.flush()).rejects.toThrow()
43 expect(remote.reads()).toBe(0)
44 expect(remote.writes()).toBe(0)
45 await snapshot.load()
46 await snapshot.flush()
47 expect(remote.writes()).toBe(1)
48 })
49
50 it('permits a first save only after confirmed object absence', async () => {
51 const remote = bucket('', new S3RequestError('GET', 404, 'NoSuchKey'))
52 const snapshot = new ObjectSnapshot(remote.client, 'b', 'snapshot.json', () => ({}))
53 expect(await snapshot.load()).toBeNull()
54 await snapshot.flush()
55 expect(remote.writes()).toBe(1)
56 })
57
58 it('rejects corrupt snapshots without replacing them', async () => {
59 for (const body of ['', '{broken']) {
60 const remote = bucket(body)
61 const snapshot = new ObjectSnapshot(remote.client, 'b', 'snapshot.json', () => ({}))
62 await expect(snapshot.load()).rejects.toThrow()
63 await expect(snapshot.flush()).rejects.toThrow()
64 expect(remote.writes()).toBe(0)
65 }
66 })
67
68 it('metadata readiness rejects an outage and flush cannot overwrite the index', async () => {
69 const remote = bucket('{}', new S3RequestError('GET', 503, 'ServiceUnavailable'))
70 const storage = new ObjectMetadataStorage(remote.client, 'b')
71 await expect(storage.ready()).rejects.toThrow('503')
72 await expect(storage.flush()).rejects.toThrow()
73 expect(remote.writes()).toBe(0)
74 })
75
76 it('analytics readiness rejects an outage and flush cannot overwrite counters', async () => {
77 const remote = bucket('{}', new S3RequestError('GET', 503, 'ServiceUnavailable'))
78 const storage = new ObjectAnalytics(remote.client, 'b')
79 await expect(storage.ready()).rejects.toThrow('503')
80 await expect(storage.flush()).rejects.toThrow()
81 expect(remote.writes()).toBe(0)
82 })
83})