Skip to content

Commit 8bd6d58

Browse files
nullcoderClaude
andcommitted
refactor: update storage client to support versioning per SPEC.md
- Switch from separate blobs/ directory to versions/{id}/{timestamp}.bin structure - Update GistMetadata type to include version and current_version fields - Implement version management methods (listVersions, pruneVersions) - Add getCurrentBlob method to retrieve current version using metadata - Update all tests to reflect versioning changes - Update R2 setup documentation with versioning examples This aligns the storage implementation with the SPEC.md design where: - All blobs are stored as versioned files - Metadata tracks the current_version timestamp - New versions just add a timestamp file - Last 50 versions are kept (older ones pruned) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@ghostpaste.dev>
1 parent 82ce292 commit 8bd6d58

File tree

4 files changed

+288
-43
lines changed

4 files changed

+288
-43
lines changed

docs/R2_SETUP.md

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,22 @@ curl -X DELETE "http://localhost:8788/api/r2-test?key=test-file"
8787

8888
## Storage Structure
8989

90-
GhostPaste uses the following R2 object structure:
90+
GhostPaste uses a versioned storage structure following the SPEC.md design:
9191

9292
```
93-
metadata/{gistId}.json # Unencrypted metadata
94-
blobs/{gistId} # Encrypted binary data
95-
temp/{gistId} # Temporary storage (optional)
93+
metadata/{gistId}.json # Unencrypted metadata (points to current version)
94+
versions/{gistId}/{timestamp}.bin # Encrypted blob versions
95+
temp/{gistId} # Temporary storage (optional)
9696
```
9797

98+
Key points:
99+
100+
- All blobs are stored as versioned files under `versions/`
101+
- Metadata tracks the `current_version` timestamp
102+
- No separate `blobs/` directory - everything is versioned
103+
- New versions just add a timestamp file
104+
- Last 50 versions are kept (older ones pruned)
105+
98106
## R2 Storage Client
99107

100108
GhostPaste includes a type-safe R2 storage client wrapper (`lib/storage.ts`) that provides:
@@ -120,16 +128,25 @@ await storage.putMetadata(gistId, metadata);
120128
// Retrieve metadata
121129
const metadata = await storage.getMetadata(gistId);
122130

123-
// Store encrypted blob
124-
await storage.putBlob(gistId, encryptedData);
131+
// Store encrypted blob (returns timestamp for the version)
132+
const timestamp = await storage.putBlob(gistId, encryptedData);
133+
134+
// Retrieve specific version
135+
const blob = await storage.getBlob(gistId, timestamp);
136+
137+
// Retrieve current version
138+
const currentBlob = await storage.getCurrentBlob(gistId);
139+
140+
// List all versions for a gist
141+
const versions = await storage.listVersions(gistId);
125142

126-
// Retrieve encrypted blob
127-
const blob = await storage.getBlob(gistId);
143+
// Prune old versions (keep last 50)
144+
const deletedCount = await storage.pruneVersions(gistId, 50);
128145

129146
// Check if gist exists
130147
const exists = await storage.exists(gistId);
131148

132-
// Delete gist (both metadata and blob)
149+
// Delete gist (metadata and all versions)
133150
await storage.deleteGist(gistId);
134151

135152
// List gists with pagination
@@ -143,7 +160,7 @@ The storage client uses consistent key patterns:
143160
```typescript
144161
const StorageKeys = {
145162
metadata: (id: string) => `metadata/${id}.json`,
146-
blob: (id: string) => `blobs/${id}`,
163+
version: (id: string, timestamp: string) => `versions/${id}/${timestamp}.bin`,
147164
temp: (id: string) => `temp/${id}`,
148165
};
149166
```

lib/storage.test.ts

Lines changed: 162 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ describe("R2Storage", () => {
9595
created_at: "2024-01-01T00:00:00Z",
9696
updated_at: "2024-01-01T00:00:00Z",
9797
version: 1,
98+
current_version: "2024-01-01T00:00:00Z",
9899
total_size: 1000,
99100
blob_count: 1,
100101
encrypted_metadata: {
@@ -149,6 +150,7 @@ describe("R2Storage", () => {
149150
created_at: "2024-01-01T00:00:00Z",
150151
updated_at: "2024-01-01T00:00:00Z",
151152
version: 1,
153+
current_version: "2024-01-01T00:00:00Z",
152154
total_size: 1000,
153155
blob_count: 1,
154156
encrypted_metadata: {
@@ -191,18 +193,24 @@ describe("R2Storage", () => {
191193
});
192194

193195
describe("putBlob", () => {
194-
it("should store blob successfully", async () => {
196+
it("should store blob successfully and return timestamp", async () => {
195197
await storage.initialize();
196198
const data = new Uint8Array([1, 2, 3, 4]);
197-
await storage.putBlob("test-id", data);
198-
199-
expect(mockBucket.put).toHaveBeenCalledWith("blobs/test-id", data, {
200-
httpMetadata: { contentType: "application/octet-stream" },
201-
customMetadata: {
202-
type: "blob",
203-
size: "4",
204-
},
205-
});
199+
const timestamp = await storage.putBlob("test-id", data);
200+
201+
expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
202+
expect(mockBucket.put).toHaveBeenCalledWith(
203+
expect.stringMatching(/^versions\/test-id\/.*\.bin$/),
204+
data,
205+
{
206+
httpMetadata: { contentType: "application/octet-stream" },
207+
customMetadata: {
208+
type: "version",
209+
size: "4",
210+
timestamp: expect.any(String),
211+
},
212+
}
213+
);
206214
});
207215

208216
it("should handle put errors", async () => {
@@ -216,39 +224,98 @@ describe("R2Storage", () => {
216224
});
217225

218226
describe("getBlob", () => {
219-
it("should retrieve blob successfully", async () => {
227+
it("should retrieve blob by timestamp successfully", async () => {
220228
await storage.initialize();
221229
const mockData = new Uint8Array([1, 2, 3, 4]);
222230
mockBucket.get.mockResolvedValue({
223231
arrayBuffer: vi.fn().mockResolvedValue(mockData.buffer),
224232
});
225233

226-
const result = await storage.getBlob("test-id");
234+
const timestamp = "2024-01-01T00:00:00Z";
235+
const result = await storage.getBlob("test-id", timestamp);
227236
expect(result).toEqual(mockData);
228-
expect(mockBucket.get).toHaveBeenCalledWith("blobs/test-id");
237+
expect(mockBucket.get).toHaveBeenCalledWith(
238+
"versions/test-id/2024-01-01T00:00:00Z.bin"
239+
);
229240
});
230241

231242
it("should return null if blob not found", async () => {
232243
await storage.initialize();
233244
mockBucket.get.mockResolvedValue(null);
234245

235-
const result = await storage.getBlob("test-id");
246+
const result = await storage.getBlob("test-id", "2024-01-01T00:00:00Z");
247+
expect(result).toBeNull();
248+
});
249+
});
250+
251+
describe("getCurrentBlob", () => {
252+
const mockMetadata: GistMetadata = {
253+
id: "test-id",
254+
created_at: "2024-01-01T00:00:00Z",
255+
updated_at: "2024-01-01T00:00:00Z",
256+
version: 1,
257+
current_version: "2024-01-01T00:00:00Z",
258+
total_size: 1000,
259+
blob_count: 1,
260+
encrypted_metadata: {
261+
iv: "test-iv",
262+
data: "test-data",
263+
},
264+
};
265+
266+
it("should retrieve current blob using metadata", async () => {
267+
await storage.initialize();
268+
const mockData = new Uint8Array([1, 2, 3, 4]);
269+
270+
// Mock metadata get
271+
mockBucket.get
272+
.mockResolvedValueOnce({
273+
text: vi.fn().mockResolvedValue(JSON.stringify(mockMetadata)),
274+
})
275+
// Mock blob get
276+
.mockResolvedValueOnce({
277+
arrayBuffer: vi.fn().mockResolvedValue(mockData.buffer),
278+
});
279+
280+
const result = await storage.getCurrentBlob("test-id");
281+
expect(result).toEqual(mockData);
282+
});
283+
284+
it("should return null if metadata not found", async () => {
285+
await storage.initialize();
286+
mockBucket.get.mockResolvedValue(null);
287+
288+
const result = await storage.getCurrentBlob("test-id");
236289
expect(result).toBeNull();
237290
});
238291
});
239292

240293
describe("deleteGist", () => {
241-
it("should delete both metadata and blob", async () => {
294+
it("should delete metadata and all versions", async () => {
242295
await storage.initialize();
296+
mockBucket.list.mockResolvedValue({
297+
objects: [
298+
{ key: "versions/test-id/2024-01-01T00:00:00Z.bin" },
299+
{ key: "versions/test-id/2024-01-02T00:00:00Z.bin" },
300+
],
301+
truncated: false,
302+
});
303+
243304
await storage.deleteGist("test-id");
244305

245306
expect(mockBucket.delete).toHaveBeenCalledWith("metadata/test-id.json");
246-
expect(mockBucket.delete).toHaveBeenCalledWith("blobs/test-id");
247-
expect(mockBucket.delete).toHaveBeenCalledTimes(2);
307+
expect(mockBucket.delete).toHaveBeenCalledWith(
308+
"versions/test-id/2024-01-01T00:00:00Z.bin"
309+
);
310+
expect(mockBucket.delete).toHaveBeenCalledWith(
311+
"versions/test-id/2024-01-02T00:00:00Z.bin"
312+
);
313+
expect(mockBucket.delete).toHaveBeenCalledTimes(3);
248314
});
249315

250316
it("should handle delete errors", async () => {
251317
await storage.initialize();
318+
mockBucket.list.mockResolvedValue({ objects: [], truncated: false });
252319
mockBucket.delete.mockRejectedValue(new Error("Delete failed"));
253320

254321
await expect(storage.deleteGist("test-id")).rejects.toThrow(AppError);
@@ -288,6 +355,7 @@ describe("R2Storage", () => {
288355
created_at: "2024-01-01T00:00:00Z",
289356
updated_at: "2024-01-01T00:00:00Z",
290357
version: 1,
358+
current_version: "2024-01-01T00:00:00Z",
291359
total_size: 1000,
292360
blob_count: 1,
293361
encrypted_metadata: {
@@ -365,12 +433,88 @@ describe("R2Storage", () => {
365433
expect(stats.totalSize).toBe(300);
366434
});
367435
});
436+
437+
describe("listVersions", () => {
438+
it("should list all versions for a gist", async () => {
439+
await storage.initialize();
440+
mockBucket.list.mockResolvedValue({
441+
objects: [
442+
{ key: "versions/test-id/2024-01-02T00:00:00Z.bin", size: 200 },
443+
{ key: "versions/test-id/2024-01-01T00:00:00Z.bin", size: 100 },
444+
],
445+
truncated: false,
446+
});
447+
448+
const versions = await storage.listVersions("test-id");
449+
450+
expect(versions).toHaveLength(2);
451+
expect(versions[0]).toEqual({
452+
timestamp: "2024-01-02T00:00:00Z",
453+
size: 200,
454+
});
455+
expect(versions[1]).toEqual({
456+
timestamp: "2024-01-01T00:00:00Z",
457+
size: 100,
458+
});
459+
});
460+
461+
it("should handle list errors", async () => {
462+
await storage.initialize();
463+
mockBucket.list.mockRejectedValue(new Error("List failed"));
464+
465+
await expect(storage.listVersions("test-id")).rejects.toThrow(AppError);
466+
});
467+
});
468+
469+
describe("pruneVersions", () => {
470+
it("should delete old versions beyond limit", async () => {
471+
await storage.initialize();
472+
mockBucket.list.mockResolvedValue({
473+
objects: [
474+
{ key: "versions/test-id/2024-01-05T00:00:00Z.bin", size: 100 },
475+
{ key: "versions/test-id/2024-01-04T00:00:00Z.bin", size: 100 },
476+
{ key: "versions/test-id/2024-01-03T00:00:00Z.bin", size: 100 },
477+
{ key: "versions/test-id/2024-01-02T00:00:00Z.bin", size: 100 },
478+
{ key: "versions/test-id/2024-01-01T00:00:00Z.bin", size: 100 },
479+
],
480+
truncated: false,
481+
});
482+
483+
const deleted = await storage.pruneVersions("test-id", 3);
484+
485+
expect(deleted).toBe(2);
486+
expect(mockBucket.delete).toHaveBeenCalledWith(
487+
"versions/test-id/2024-01-02T00:00:00Z.bin"
488+
);
489+
expect(mockBucket.delete).toHaveBeenCalledWith(
490+
"versions/test-id/2024-01-01T00:00:00Z.bin"
491+
);
492+
});
493+
494+
it("should not delete if under limit", async () => {
495+
await storage.initialize();
496+
mockBucket.list.mockResolvedValue({
497+
objects: [
498+
{ key: "versions/test-id/2024-01-02T00:00:00Z.bin", size: 100 },
499+
{ key: "versions/test-id/2024-01-01T00:00:00Z.bin", size: 100 },
500+
],
501+
truncated: false,
502+
});
503+
504+
const deleted = await storage.pruneVersions("test-id", 50);
505+
506+
expect(deleted).toBe(0);
507+
expect(mockBucket.delete).not.toHaveBeenCalled();
508+
});
509+
});
368510
});
369511

370512
describe("StorageKeys", () => {
371513
it("should generate correct keys", () => {
372514
expect(StorageKeys.metadata("test-id")).toBe("metadata/test-id.json");
373-
expect(StorageKeys.blob("test-id")).toBe("blobs/test-id");
515+
expect(StorageKeys.version("test-id", "2024-01-01T00:00:00Z")).toBe(
516+
"versions/test-id/2024-01-01T00:00:00Z.bin"
517+
);
374518
expect(StorageKeys.temp("test-id")).toBe("temp/test-id");
375519
});
376520
});

0 commit comments

Comments
 (0)