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/build-status.test.tsmodified+5-4
Changes to packages/registry/src/build-status.test.ts
@@ -1,3 +1,4 @@
1import { S3RequestError } from './storage/aws-client'
12import { describe, expect, it } from 'bun:test'
23import { BuildStatusStore } from './storage/build-status'
34
@@ -7,7 +8,7 @@ function mockBucket() {
78 const client = {
89 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
910 const value = objects.get(key)
10 if (!value) throw new Error('S3 GET failed: 404 Not Found')
11 if (!value) throw new S3RequestError('GET', 404, 'NoSuchKey')
1112 return value
1213 },
1314 async putObject(input: { key: string, body: Buffer | string }): Promise<void> {
@@ -74,7 +75,7 @@ describe('BuildStatusStore partial coverage', () => {
7475 const client = {
7576 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
7677 const value = objects.get(key)
77 if (!value) throw new Error('S3 GET failed: 404 Not Found')
78 if (!value) throw new S3RequestError('GET', 404, 'NoSuchKey')
7879 return value
7980 },
8081 async putObject(input: { key: string, body: Buffer | string }): Promise<void> {
@@ -135,7 +136,7 @@ describe('BuildStatusStore partial coverage', () => {
135136 const client = {
136137 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
137138 const value = objects.get(key)
138 if (!value) throw new Error('S3 GET failed: 404 Not Found')
139 if (!value) throw new S3RequestError('GET', 404, 'NoSuchKey')
139140 return value
140141 },
141142 async putObject(input: { key: string, body: Buffer | string }): Promise<void> {
@@ -196,7 +197,7 @@ describe('BuildStatusStore retired platforms', () => {
196197 const client = {
197198 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
198199 const value = objects.get(key)
199 if (!value) throw new Error('S3 GET failed: 404 Not Found')
200 if (!value) throw new S3RequestError('GET', 404, 'NoSuchKey')
200201 return value
201202 },
202203 async putObject(input: { key: string, body: Buffer | string }): Promise<void> {
packages/registry/src/object-analytics.test.tsmodified+2-1
Changes to packages/registry/src/object-analytics.test.ts
@@ -8,6 +8,7 @@
88 * starts empty.
99 */
1010
11import { S3RequestError } from './storage/aws-client'
1112import { describe, expect, it } from 'bun:test'
1213import { ObjectAnalytics } from './storage/object-analytics'
1314
@@ -17,7 +18,7 @@ function mockBucket() {
1718 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
1819 const v = store.get(key)
1920 if (!v)
20 throw new Error('S3 GET failed: 404 Not Found')
21 throw new S3RequestError('GET', 404, 'NoSuchKey')
2122 return v
2223 },
2324 async putObject(o: { bucket: string, key: string, body: Buffer | string }): Promise<void> {
packages/registry/src/object-metadata.test.tsmodified+2-1
Changes to packages/registry/src/object-metadata.test.ts
@@ -7,6 +7,7 @@
77 * used to hold), and that a missing snapshot starts empty rather than throwing.
88 */
99
10import { S3RequestError } from './storage/aws-client'
1011import type { CommitPublish, PackageMetadata } from './types'
1112import { describe, expect, it } from 'bun:test'
1213import { ObjectMetadataStorage } from './storage/object-metadata'
@@ -18,7 +19,7 @@ function mockBucket() {
1819 async getObjectBuffer(_bucket: string, key: string): Promise<Buffer> {
1920 const v = store.get(key)
2021 if (!v)
21 throw new Error('S3 GET failed: 404 Not Found')
22 throw new S3RequestError('GET', 404, 'NoSuchKey')
2223 return v
2324 },
2425 async putObject(o: { bucket: string, key: string, body: Buffer | string }): Promise<void> {
packages/registry/src/object-package-storage.test.tsmodified+2-1
Changes to packages/registry/src/object-package-storage.test.ts
@@ -1,3 +1,4 @@
1import { S3RequestError } from './storage/aws-client'
12import { describe, expect, test } from 'bun:test'
23import { ObjectPhpStorage } from './php'
34import { ObjectZigStorage } from './zig'
@@ -14,7 +15,7 @@ class MockS3 {
1415 async getObjectBuffer(bucket: string, key: string): Promise<Buffer> {
1516 const v = this.store.get(`${bucket}/${key}`)
1617 if (!v)
17 throw new Error('404 Not Found')
18 throw new S3RequestError('GET', 404, 'NoSuchKey')
1819 return v
1920 }
2021
packages/registry/src/server.tsmodified+10-1
Changes to packages/registry/src/server.ts
@@ -6846,7 +6846,16 @@ if (import.meta.main) {
68466846 // Ensure auth storage uses the same DynamoDB table as the registry
68476847 const authStorage = createAuthStorage(dynamoTable, awsRegion)
68486848
6849 const { start } = createServer(registry, port, analytics, undefined, undefined, undefined, authStorage)
6849 const zigStorage = createZigStorage()
6850 const phpStorage = createPhpStorage()
6851 // Hydrate durable state before accepting any request that can mutate it.
6852 // Only a confirmed missing object can initialize an empty registry.
6853 await Promise.all([registry.metadata, analytics, authStorage, zigStorage, phpStorage].map(async (store) => {
6854 if ('ready' in store && typeof store.ready === 'function')
6855 await store.ready()
6856 }))
6857
6858 const { start } = createServer(registry, port, analytics, zigStorage, undefined, phpStorage, authStorage)
68506859 start()
68516860
68526861 console.log('\nEnvironment:')
packages/registry/src/storage/object-analytics.tsmodified+8-7
Changes to packages/registry/src/storage/object-analytics.ts
@@ -1,4 +1,5 @@
11import type { S3Client } from './aws-client'
2import { isS3ObjectNotFound } from './aws-client'
23import { InMemoryAnalytics } from '../analytics'
34
45/**
@@ -16,6 +17,7 @@ import { InMemoryAnalytics } from '../analytics'
1617 * are debounced (counters accumulate in memory between flushes).
1718 */
1819export class ObjectAnalytics extends InMemoryAnalytics {
20 private hydrated = false
1921 private s3: S3Client
2022 private bucket: string
2123 private key: string
@@ -39,16 +41,12 @@ export class ObjectAnalytics extends InMemoryAnalytics {
3941 private async load(): Promise<void> {
4042 try {
4143 const buf = await this.s3.getObjectBuffer(this.bucket, this.key)
42 if (!buf || buf.byteLength === 0)
43 return
4444 this.applyAnalyticsState(JSON.parse(buf.toString('utf-8')))
45 this.hydrated = true
4546 }
4647 catch (err) {
47 const msg = (err as Error).message || ''
48 // A missing object on first run is expected (404/403); start empty otherwise.
49 if (!/\b40[34]\b/.test(msg)) {
50 console.error(`ObjectAnalytics: failed to load ${this.bucket}/${this.key}: ${msg}. Starting empty.`)
51 }
48 if (!isS3ObjectNotFound(err)) throw err
49 this.hydrated = true
5250 }
5351 }
5452
@@ -66,6 +64,8 @@ export class ObjectAnalytics extends InMemoryAnalytics {
6664 }
6765
6866 private async save(): Promise<void> {
67 if (!this.hydrated)
68 throw new Error(`Snapshot not hydrated: ${this.key}`)
6969 while (this.savePromise) await this.savePromise
7070 this.savePromise = this.doSave()
7171 try {
@@ -88,6 +88,7 @@ export class ObjectAnalytics extends InMemoryAnalytics {
8888
8989 /** Flush any pending save immediately (e.g. before shutdown). */
9090 async flush(): Promise<void> {
91 await this.loaded
9192 if (this.saveTimeout) {
9293 clearTimeout(this.saveTimeout)
9394 this.saveTimeout = null
packages/registry/src/storage/object-metadata.tsmodified+8-9
Changes to packages/registry/src/storage/object-metadata.ts
@@ -1,4 +1,5 @@
11import type { S3Client } from './aws-client'
2import { isS3ObjectNotFound } from './aws-client'
23import { InMemoryMetadataStorage } from './metadata'
34
45/**
@@ -15,6 +16,7 @@ import { InMemoryMetadataStorage } from './metadata'
1516 * snapshot from the bucket.
1617 */
1718export class ObjectMetadataStorage extends InMemoryMetadataStorage {
19 private hydrated = false
1820 private s3: S3Client
1921 private bucket: string
2022 private key: string
@@ -38,19 +40,13 @@ export class ObjectMetadataStorage extends InMemoryMetadataStorage {
3840 private async load(): Promise<void> {
3941 try {
4042 const buf = await this.s3.getObjectBuffer(this.bucket, this.key)
41 if (!buf || buf.byteLength === 0)
42 return
4343 const data = JSON.parse(buf.toString('utf-8'))
4444 this.applyState(data)
45 this.hydrated = true
4546 }
4647 catch (err) {
47 // A missing object on first run is expected (404/403). Anything else we
48 // log loudly but still start empty rather than block the server boot —
49 // and we do NOT overwrite the remote object until the next real save.
50 const msg = (err as Error).message || ''
51 if (!/\b40[34]\b/.test(msg)) {
52 console.error(`ObjectMetadataStorage: failed to load ${this.bucket}/${this.key}: ${msg}. Starting empty.`)
53 }
48 if (!isS3ObjectNotFound(err)) throw err
49 this.hydrated = true
5450 }
5551 }
5652
@@ -65,6 +61,8 @@ export class ObjectMetadataStorage extends InMemoryMetadataStorage {
6561 }
6662
6763 private async save(): Promise<void> {
64 if (!this.hydrated)
65 throw new Error(`Snapshot not hydrated: ${this.key}`)
6866 // Serialize saves so a slow upload can't be overtaken by a newer one.
6967 while (this.savePromise) await this.savePromise
7068 this.savePromise = this._doSave()
@@ -88,6 +86,7 @@ export class ObjectMetadataStorage extends InMemoryMetadataStorage {
8886
8987 /** Flush any pending save immediately (e.g. before shutdown). */
9088 async flush(): Promise<void> {
89 await this.loaded
9190 if (this.saveTimeout) {
9291 clearTimeout(this.saveTimeout)
9392 this.saveTimeout = null
packages/registry/src/storage/object-snapshot.tsmodified+9-9
Changes to packages/registry/src/storage/object-snapshot.ts
@@ -1,4 +1,5 @@
11import type { S3Client } from './aws-client'
2import { isS3ObjectNotFound } from './aws-client'
23
34/**
45 * Reusable debounced JSON-snapshot persistence to an S3-compatible bucket.
@@ -13,6 +14,7 @@ import type { S3Client } from './aws-client'
1314 * the bucket under their own keys by the owning store.
1415 */
1516export class ObjectSnapshot {
17 private hydrated = false
1618 private saveTimeout: ReturnType<typeof setTimeout> | null = null
1719 private savePromise: Promise<void> | null = null
1820
@@ -27,17 +29,13 @@ export class ObjectSnapshot {
2729 async load(): Promise<unknown | null> {
2830 try {
2931 const buf = await this.s3.getObjectBuffer(this.bucket, this.key)
30 if (!buf || buf.byteLength === 0)
31 return null
32 return JSON.parse(buf.toString('utf-8'))
32 const state = JSON.parse(buf.toString('utf-8'))
33 this.hydrated = true
34 return state
3335 }
3436 catch (err) {
35 // A missing object on first run is expected (404/403). Anything else we
36 // surface but still start empty rather than block boot — and we do NOT
37 // overwrite the remote object until the next real save.
38 const msg = (err as Error).message || ''
39 if (!/\b40[34]\b/.test(msg))
40 console.error(`ObjectSnapshot: failed to load ${this.bucket}/${this.key}: ${msg}. Starting empty.`)
37 if (!isS3ObjectNotFound(err)) throw err
38 this.hydrated = true
4139 return null
4240 }
4341 }
@@ -53,6 +51,8 @@ export class ObjectSnapshot {
5351 }
5452
5553 private async save(): Promise<void> {
54 if (!this.hydrated)
55 throw new Error(`Snapshot not hydrated: ${this.key}`)
5656 // Serialize saves so a slow upload can't be overtaken by a newer one.
5757 while (this.savePromise) await this.savePromise
5858 this.savePromise = this.s3.putObject({