Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
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>
  • Loading branch information
nullcoder and Claude committed Jun 7, 2025
commit 8bd6d58bde9558ddee9bc24151a2f12796821227
37 changes: 27 additions & 10 deletions docs/R2_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,22 @@ curl -X DELETE "http://localhost:8788/api/r2-test?key=test-file"

## Storage Structure

GhostPaste uses the following R2 object structure:
GhostPaste uses a versioned storage structure following the SPEC.md design:

```
metadata/{gistId}.json # Unencrypted metadata
blobs/{gistId} # Encrypted binary data
temp/{gistId} # Temporary storage (optional)
metadata/{gistId}.json # Unencrypted metadata (points to current version)
versions/{gistId}/{timestamp}.bin # Encrypted blob versions
temp/{gistId} # Temporary storage (optional)
```

Key points:

- All blobs are stored as versioned files under `versions/`
- Metadata tracks the `current_version` timestamp
- No separate `blobs/` directory - everything is versioned
- New versions just add a timestamp file
- Last 50 versions are kept (older ones pruned)

## R2 Storage Client

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

// Store encrypted blob
await storage.putBlob(gistId, encryptedData);
// Store encrypted blob (returns timestamp for the version)
const timestamp = await storage.putBlob(gistId, encryptedData);

// Retrieve specific version
const blob = await storage.getBlob(gistId, timestamp);

// Retrieve current version
const currentBlob = await storage.getCurrentBlob(gistId);

// List all versions for a gist
const versions = await storage.listVersions(gistId);

// Retrieve encrypted blob
const blob = await storage.getBlob(gistId);
// Prune old versions (keep last 50)
const deletedCount = await storage.pruneVersions(gistId, 50);

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

// Delete gist (both metadata and blob)
// Delete gist (metadata and all versions)
await storage.deleteGist(gistId);

// List gists with pagination
Expand All @@ -143,7 +160,7 @@ The storage client uses consistent key patterns:
```typescript
const StorageKeys = {
metadata: (id: string) => `metadata/${id}.json`,
blob: (id: string) => `blobs/${id}`,
version: (id: string, timestamp: string) => `versions/${id}/${timestamp}.bin`,
temp: (id: string) => `temp/${id}`,
};
```
Expand Down
180 changes: 162 additions & 18 deletions lib/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe("R2Storage", () => {
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
version: 1,
current_version: "2024-01-01T00:00:00Z",
total_size: 1000,
blob_count: 1,
encrypted_metadata: {
Expand Down Expand Up @@ -149,6 +150,7 @@ describe("R2Storage", () => {
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
version: 1,
current_version: "2024-01-01T00:00:00Z",
total_size: 1000,
blob_count: 1,
encrypted_metadata: {
Expand Down Expand Up @@ -191,18 +193,24 @@ describe("R2Storage", () => {
});

describe("putBlob", () => {
it("should store blob successfully", async () => {
it("should store blob successfully and return timestamp", async () => {
await storage.initialize();
const data = new Uint8Array([1, 2, 3, 4]);
await storage.putBlob("test-id", data);

expect(mockBucket.put).toHaveBeenCalledWith("blobs/test-id", data, {
httpMetadata: { contentType: "application/octet-stream" },
customMetadata: {
type: "blob",
size: "4",
},
});
const timestamp = await storage.putBlob("test-id", data);

expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
expect(mockBucket.put).toHaveBeenCalledWith(
expect.stringMatching(/^versions\/test-id\/.*\.bin$/),
data,
{
httpMetadata: { contentType: "application/octet-stream" },
customMetadata: {
type: "version",
size: "4",
timestamp: expect.any(String),
},
}
);
});

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

describe("getBlob", () => {
it("should retrieve blob successfully", async () => {
it("should retrieve blob by timestamp successfully", async () => {
await storage.initialize();
const mockData = new Uint8Array([1, 2, 3, 4]);
mockBucket.get.mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(mockData.buffer),
});

const result = await storage.getBlob("test-id");
const timestamp = "2024-01-01T00:00:00Z";
const result = await storage.getBlob("test-id", timestamp);
expect(result).toEqual(mockData);
expect(mockBucket.get).toHaveBeenCalledWith("blobs/test-id");
expect(mockBucket.get).toHaveBeenCalledWith(
"versions/test-id/2024-01-01T00:00:00Z.bin"
);
});

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

const result = await storage.getBlob("test-id");
const result = await storage.getBlob("test-id", "2024-01-01T00:00:00Z");
expect(result).toBeNull();
});
});

describe("getCurrentBlob", () => {
const mockMetadata: GistMetadata = {
id: "test-id",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
version: 1,
current_version: "2024-01-01T00:00:00Z",
total_size: 1000,
blob_count: 1,
encrypted_metadata: {
iv: "test-iv",
data: "test-data",
},
};

it("should retrieve current blob using metadata", async () => {
await storage.initialize();
const mockData = new Uint8Array([1, 2, 3, 4]);

// Mock metadata get
mockBucket.get
.mockResolvedValueOnce({
text: vi.fn().mockResolvedValue(JSON.stringify(mockMetadata)),
})
// Mock blob get
.mockResolvedValueOnce({
arrayBuffer: vi.fn().mockResolvedValue(mockData.buffer),
});

const result = await storage.getCurrentBlob("test-id");
expect(result).toEqual(mockData);
});

it("should return null if metadata not found", async () => {
await storage.initialize();
mockBucket.get.mockResolvedValue(null);

const result = await storage.getCurrentBlob("test-id");
expect(result).toBeNull();
});
});

describe("deleteGist", () => {
it("should delete both metadata and blob", async () => {
it("should delete metadata and all versions", async () => {
await storage.initialize();
mockBucket.list.mockResolvedValue({
objects: [
{ key: "versions/test-id/2024-01-01T00:00:00Z.bin" },
{ key: "versions/test-id/2024-01-02T00:00:00Z.bin" },
],
truncated: false,
});

await storage.deleteGist("test-id");

expect(mockBucket.delete).toHaveBeenCalledWith("metadata/test-id.json");
expect(mockBucket.delete).toHaveBeenCalledWith("blobs/test-id");
expect(mockBucket.delete).toHaveBeenCalledTimes(2);
expect(mockBucket.delete).toHaveBeenCalledWith(
"versions/test-id/2024-01-01T00:00:00Z.bin"
);
expect(mockBucket.delete).toHaveBeenCalledWith(
"versions/test-id/2024-01-02T00:00:00Z.bin"
);
expect(mockBucket.delete).toHaveBeenCalledTimes(3);
});

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

await expect(storage.deleteGist("test-id")).rejects.toThrow(AppError);
Expand Down Expand Up @@ -288,6 +355,7 @@ describe("R2Storage", () => {
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
version: 1,
current_version: "2024-01-01T00:00:00Z",
total_size: 1000,
blob_count: 1,
encrypted_metadata: {
Expand Down Expand Up @@ -365,12 +433,88 @@ describe("R2Storage", () => {
expect(stats.totalSize).toBe(300);
});
});

describe("listVersions", () => {
it("should list all versions for a gist", async () => {
await storage.initialize();
mockBucket.list.mockResolvedValue({
objects: [
{ key: "versions/test-id/2024-01-02T00:00:00Z.bin", size: 200 },
{ key: "versions/test-id/2024-01-01T00:00:00Z.bin", size: 100 },
],
truncated: false,
});

const versions = await storage.listVersions("test-id");

expect(versions).toHaveLength(2);
expect(versions[0]).toEqual({
timestamp: "2024-01-02T00:00:00Z",
size: 200,
});
expect(versions[1]).toEqual({
timestamp: "2024-01-01T00:00:00Z",
size: 100,
});
});

it("should handle list errors", async () => {
await storage.initialize();
mockBucket.list.mockRejectedValue(new Error("List failed"));

await expect(storage.listVersions("test-id")).rejects.toThrow(AppError);
});
});

describe("pruneVersions", () => {
it("should delete old versions beyond limit", async () => {
await storage.initialize();
mockBucket.list.mockResolvedValue({
objects: [
{ key: "versions/test-id/2024-01-05T00:00:00Z.bin", size: 100 },
{ key: "versions/test-id/2024-01-04T00:00:00Z.bin", size: 100 },
{ key: "versions/test-id/2024-01-03T00:00:00Z.bin", size: 100 },
{ key: "versions/test-id/2024-01-02T00:00:00Z.bin", size: 100 },
{ key: "versions/test-id/2024-01-01T00:00:00Z.bin", size: 100 },
],
truncated: false,
});

const deleted = await storage.pruneVersions("test-id", 3);

expect(deleted).toBe(2);
expect(mockBucket.delete).toHaveBeenCalledWith(
"versions/test-id/2024-01-02T00:00:00Z.bin"
);
expect(mockBucket.delete).toHaveBeenCalledWith(
"versions/test-id/2024-01-01T00:00:00Z.bin"
);
});

it("should not delete if under limit", async () => {
await storage.initialize();
mockBucket.list.mockResolvedValue({
objects: [
{ key: "versions/test-id/2024-01-02T00:00:00Z.bin", size: 100 },
{ key: "versions/test-id/2024-01-01T00:00:00Z.bin", size: 100 },
],
truncated: false,
});

const deleted = await storage.pruneVersions("test-id", 50);

expect(deleted).toBe(0);
expect(mockBucket.delete).not.toHaveBeenCalled();
});
});
});

describe("StorageKeys", () => {
it("should generate correct keys", () => {
expect(StorageKeys.metadata("test-id")).toBe("metadata/test-id.json");
expect(StorageKeys.blob("test-id")).toBe("blobs/test-id");
expect(StorageKeys.version("test-id", "2024-01-01T00:00:00Z")).toBe(
"versions/test-id/2024-01-01T00:00:00Z.bin"
);
expect(StorageKeys.temp("test-id")).toBe("temp/test-id");
});
});
Expand Down
Loading