gitx.do 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/durable-object/object-store.d.ts +113 -0
- package/dist/durable-object/object-store.d.ts.map +1 -0
- package/dist/durable-object/object-store.js +387 -0
- package/dist/durable-object/object-store.js.map +1 -0
- package/dist/durable-object/schema.d.ts +17 -0
- package/dist/durable-object/schema.d.ts.map +1 -0
- package/dist/durable-object/schema.js +43 -0
- package/dist/durable-object/schema.js.map +1 -0
- package/dist/durable-object/wal.d.ts +111 -0
- package/dist/durable-object/wal.d.ts.map +1 -0
- package/dist/durable-object/wal.js +200 -0
- package/dist/durable-object/wal.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/adapter.d.ts +231 -0
- package/dist/mcp/adapter.d.ts.map +1 -0
- package/dist/mcp/adapter.js +502 -0
- package/dist/mcp/adapter.js.map +1 -0
- package/dist/mcp/sandbox.d.ts +261 -0
- package/dist/mcp/sandbox.d.ts.map +1 -0
- package/dist/mcp/sandbox.js +983 -0
- package/dist/mcp/sandbox.js.map +1 -0
- package/dist/mcp/sdk-adapter.d.ts +413 -0
- package/dist/mcp/sdk-adapter.d.ts.map +1 -0
- package/dist/mcp/sdk-adapter.js +672 -0
- package/dist/mcp/sdk-adapter.js.map +1 -0
- package/dist/mcp/tools.d.ts +133 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +1604 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/ops/blame.d.ts +148 -0
- package/dist/ops/blame.d.ts.map +1 -0
- package/dist/ops/blame.js +754 -0
- package/dist/ops/blame.js.map +1 -0
- package/dist/ops/branch.d.ts +215 -0
- package/dist/ops/branch.d.ts.map +1 -0
- package/dist/ops/branch.js +608 -0
- package/dist/ops/branch.js.map +1 -0
- package/dist/ops/commit-traversal.d.ts +209 -0
- package/dist/ops/commit-traversal.d.ts.map +1 -0
- package/dist/ops/commit-traversal.js +755 -0
- package/dist/ops/commit-traversal.js.map +1 -0
- package/dist/ops/commit.d.ts +221 -0
- package/dist/ops/commit.d.ts.map +1 -0
- package/dist/ops/commit.js +606 -0
- package/dist/ops/commit.js.map +1 -0
- package/dist/ops/merge-base.d.ts +223 -0
- package/dist/ops/merge-base.d.ts.map +1 -0
- package/dist/ops/merge-base.js +581 -0
- package/dist/ops/merge-base.js.map +1 -0
- package/dist/ops/merge.d.ts +385 -0
- package/dist/ops/merge.d.ts.map +1 -0
- package/dist/ops/merge.js +1203 -0
- package/dist/ops/merge.js.map +1 -0
- package/dist/ops/tag.d.ts +182 -0
- package/dist/ops/tag.d.ts.map +1 -0
- package/dist/ops/tag.js +608 -0
- package/dist/ops/tag.js.map +1 -0
- package/dist/ops/tree-builder.d.ts +82 -0
- package/dist/ops/tree-builder.d.ts.map +1 -0
- package/dist/ops/tree-builder.js +246 -0
- package/dist/ops/tree-builder.js.map +1 -0
- package/dist/ops/tree-diff.d.ts +243 -0
- package/dist/ops/tree-diff.d.ts.map +1 -0
- package/dist/ops/tree-diff.js +657 -0
- package/dist/ops/tree-diff.js.map +1 -0
- package/dist/pack/delta.d.ts +68 -0
- package/dist/pack/delta.d.ts.map +1 -0
- package/dist/pack/delta.js +343 -0
- package/dist/pack/delta.js.map +1 -0
- package/dist/pack/format.d.ts +84 -0
- package/dist/pack/format.d.ts.map +1 -0
- package/dist/pack/format.js +261 -0
- package/dist/pack/format.js.map +1 -0
- package/dist/pack/full-generation.d.ts +327 -0
- package/dist/pack/full-generation.d.ts.map +1 -0
- package/dist/pack/full-generation.js +1159 -0
- package/dist/pack/full-generation.js.map +1 -0
- package/dist/pack/generation.d.ts +118 -0
- package/dist/pack/generation.d.ts.map +1 -0
- package/dist/pack/generation.js +459 -0
- package/dist/pack/generation.js.map +1 -0
- package/dist/pack/index.d.ts +181 -0
- package/dist/pack/index.d.ts.map +1 -0
- package/dist/pack/index.js +552 -0
- package/dist/pack/index.js.map +1 -0
- package/dist/refs/branch.d.ts +224 -0
- package/dist/refs/branch.d.ts.map +1 -0
- package/dist/refs/branch.js +170 -0
- package/dist/refs/branch.js.map +1 -0
- package/dist/refs/storage.d.ts +208 -0
- package/dist/refs/storage.d.ts.map +1 -0
- package/dist/refs/storage.js +421 -0
- package/dist/refs/storage.js.map +1 -0
- package/dist/refs/tag.d.ts +230 -0
- package/dist/refs/tag.d.ts.map +1 -0
- package/dist/refs/tag.js +188 -0
- package/dist/refs/tag.js.map +1 -0
- package/dist/storage/lru-cache.d.ts +188 -0
- package/dist/storage/lru-cache.d.ts.map +1 -0
- package/dist/storage/lru-cache.js +410 -0
- package/dist/storage/lru-cache.js.map +1 -0
- package/dist/storage/object-index.d.ts +140 -0
- package/dist/storage/object-index.d.ts.map +1 -0
- package/dist/storage/object-index.js +166 -0
- package/dist/storage/object-index.js.map +1 -0
- package/dist/storage/r2-pack.d.ts +394 -0
- package/dist/storage/r2-pack.d.ts.map +1 -0
- package/dist/storage/r2-pack.js +1062 -0
- package/dist/storage/r2-pack.js.map +1 -0
- package/dist/tiered/cdc-pipeline.d.ts +316 -0
- package/dist/tiered/cdc-pipeline.d.ts.map +1 -0
- package/dist/tiered/cdc-pipeline.js +771 -0
- package/dist/tiered/cdc-pipeline.js.map +1 -0
- package/dist/tiered/migration.d.ts +242 -0
- package/dist/tiered/migration.d.ts.map +1 -0
- package/dist/tiered/migration.js +592 -0
- package/dist/tiered/migration.js.map +1 -0
- package/dist/tiered/parquet-writer.d.ts +248 -0
- package/dist/tiered/parquet-writer.d.ts.map +1 -0
- package/dist/tiered/parquet-writer.js +555 -0
- package/dist/tiered/parquet-writer.js.map +1 -0
- package/dist/tiered/read-path.d.ts +141 -0
- package/dist/tiered/read-path.d.ts.map +1 -0
- package/dist/tiered/read-path.js +204 -0
- package/dist/tiered/read-path.js.map +1 -0
- package/dist/types/objects.d.ts +53 -0
- package/dist/types/objects.d.ts.map +1 -0
- package/dist/types/objects.js +291 -0
- package/dist/types/objects.js.map +1 -0
- package/dist/types/storage.d.ts +117 -0
- package/dist/types/storage.d.ts.map +1 -0
- package/dist/types/storage.js +8 -0
- package/dist/types/storage.js.map +1 -0
- package/dist/utils/hash.d.ts +31 -0
- package/dist/utils/hash.d.ts.map +1 -0
- package/dist/utils/hash.js +60 -0
- package/dist/utils/hash.js.map +1 -0
- package/dist/utils/sha1.d.ts +26 -0
- package/dist/utils/sha1.d.ts.map +1 -0
- package/dist/utils/sha1.js +127 -0
- package/dist/utils/sha1.js.map +1 -0
- package/dist/wire/capabilities.d.ts +236 -0
- package/dist/wire/capabilities.d.ts.map +1 -0
- package/dist/wire/capabilities.js +437 -0
- package/dist/wire/capabilities.js.map +1 -0
- package/dist/wire/pkt-line.d.ts +67 -0
- package/dist/wire/pkt-line.d.ts.map +1 -0
- package/dist/wire/pkt-line.js +145 -0
- package/dist/wire/pkt-line.js.map +1 -0
- package/dist/wire/receive-pack.d.ts +302 -0
- package/dist/wire/receive-pack.d.ts.map +1 -0
- package/dist/wire/receive-pack.js +885 -0
- package/dist/wire/receive-pack.js.map +1 -0
- package/dist/wire/smart-http.d.ts +321 -0
- package/dist/wire/smart-http.d.ts.map +1 -0
- package/dist/wire/smart-http.js +654 -0
- package/dist/wire/smart-http.js.map +1 -0
- package/dist/wire/upload-pack.d.ts +333 -0
- package/dist/wire/upload-pack.d.ts.map +1 -0
- package/dist/wire/upload-pack.js +850 -0
- package/dist/wire/upload-pack.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R2 Packfile Storage
|
|
3
|
+
*
|
|
4
|
+
* Manages Git packfiles stored in Cloudflare R2 object storage.
|
|
5
|
+
* Provides functionality for:
|
|
6
|
+
* - Uploading and downloading packfiles with their indices
|
|
7
|
+
* - Multi-pack index (MIDX) for efficient object lookup across packs
|
|
8
|
+
* - Concurrent access control with locking
|
|
9
|
+
* - Pack verification and integrity checks
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Error thrown by R2 pack operations
|
|
13
|
+
*/
|
|
14
|
+
export class R2PackError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
packId;
|
|
17
|
+
constructor(message, code, packId) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.packId = packId;
|
|
21
|
+
this.name = 'R2PackError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// PACK signature bytes: "PACK"
|
|
25
|
+
const PACK_SIGNATURE = new Uint8Array([0x50, 0x41, 0x43, 0x4b]);
|
|
26
|
+
// Multi-pack index signature
|
|
27
|
+
const MIDX_SIGNATURE = new Uint8Array([0x4d, 0x49, 0x44, 0x58]); // "MIDX"
|
|
28
|
+
/**
|
|
29
|
+
* Validate a packfile header
|
|
30
|
+
*/
|
|
31
|
+
function validatePackfile(data) {
|
|
32
|
+
if (data.length < 12) {
|
|
33
|
+
throw new R2PackError('Packfile too small', 'INVALID_DATA');
|
|
34
|
+
}
|
|
35
|
+
// Check signature
|
|
36
|
+
for (let i = 0; i < 4; i++) {
|
|
37
|
+
if (data[i] !== PACK_SIGNATURE[i]) {
|
|
38
|
+
throw new R2PackError('Invalid packfile signature', 'INVALID_DATA');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Read version (big endian 4 bytes)
|
|
42
|
+
const version = (data[4] << 24) | (data[5] << 16) | (data[6] << 8) | data[7];
|
|
43
|
+
if (version !== 2 && version !== 3) {
|
|
44
|
+
throw new R2PackError(`Unsupported pack version: ${version}`, 'INVALID_DATA');
|
|
45
|
+
}
|
|
46
|
+
// Read object count (big endian 4 bytes)
|
|
47
|
+
const objectCount = (data[8] << 24) | (data[9] << 16) | (data[10] << 8) | data[11];
|
|
48
|
+
return { version, objectCount };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Compute SHA-1 checksum as hex string
|
|
52
|
+
*/
|
|
53
|
+
async function computeChecksum(data) {
|
|
54
|
+
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
|
|
55
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
56
|
+
return Array.from(hashArray)
|
|
57
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
58
|
+
.join('');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Generate a unique pack ID
|
|
62
|
+
*/
|
|
63
|
+
function generatePackId() {
|
|
64
|
+
const randomBytes = new Uint8Array(8);
|
|
65
|
+
crypto.getRandomValues(randomBytes);
|
|
66
|
+
const hex = Array.from(randomBytes)
|
|
67
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
68
|
+
.join('');
|
|
69
|
+
return `pack-${hex}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build the full key path with prefix
|
|
73
|
+
*/
|
|
74
|
+
function buildKey(prefix, path) {
|
|
75
|
+
if (!prefix) {
|
|
76
|
+
return path;
|
|
77
|
+
}
|
|
78
|
+
// Normalize prefix to have trailing slash
|
|
79
|
+
const normalizedPrefix = prefix.endsWith('/') ? prefix : prefix + '/';
|
|
80
|
+
return normalizedPrefix + path;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Generate a unique lock ID
|
|
84
|
+
*/
|
|
85
|
+
function generateLockId() {
|
|
86
|
+
const randomBytes = new Uint8Array(16);
|
|
87
|
+
crypto.getRandomValues(randomBytes);
|
|
88
|
+
return Array.from(randomBytes)
|
|
89
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
90
|
+
.join('');
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* R2 Packfile Storage class
|
|
94
|
+
*/
|
|
95
|
+
export class R2PackStorage {
|
|
96
|
+
_bucket;
|
|
97
|
+
_prefix;
|
|
98
|
+
_cacheTTL;
|
|
99
|
+
_midxCache = null;
|
|
100
|
+
_indexChecksums = new Map();
|
|
101
|
+
constructor(options) {
|
|
102
|
+
this._bucket = options.bucket;
|
|
103
|
+
this._prefix = options.prefix ?? '';
|
|
104
|
+
void (options.cacheSize ?? 100); // Reserved for LRU cache implementation
|
|
105
|
+
this._cacheTTL = options.cacheTTL ?? 3600;
|
|
106
|
+
}
|
|
107
|
+
_buildKey(path) {
|
|
108
|
+
return buildKey(this._prefix, path);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Upload a packfile and its index to R2 atomically
|
|
112
|
+
*
|
|
113
|
+
* Uses a manifest-based pattern to ensure atomic uploads:
|
|
114
|
+
* 1. Upload pack and index to staging paths
|
|
115
|
+
* 2. Create manifest in 'staging' status
|
|
116
|
+
* 3. Copy from staging to final location
|
|
117
|
+
* 4. Update manifest to 'complete' status
|
|
118
|
+
* 5. Clean up staging files
|
|
119
|
+
*
|
|
120
|
+
* If the process fails at any point, the pack is not considered complete
|
|
121
|
+
* until a valid manifest with status 'complete' exists.
|
|
122
|
+
*/
|
|
123
|
+
async uploadPackfile(packData, indexData, options) {
|
|
124
|
+
if (!this._bucket) {
|
|
125
|
+
throw new R2PackError('Bucket not available', 'NETWORK_ERROR');
|
|
126
|
+
}
|
|
127
|
+
// Validate packfile
|
|
128
|
+
const { objectCount } = validatePackfile(packData);
|
|
129
|
+
// Generate unique pack ID and checksums
|
|
130
|
+
const packId = generatePackId();
|
|
131
|
+
const packChecksum = await computeChecksum(packData);
|
|
132
|
+
const indexChecksum = await computeChecksum(indexData);
|
|
133
|
+
const uploadedAt = new Date();
|
|
134
|
+
// Store metadata for the files
|
|
135
|
+
const metadata = {
|
|
136
|
+
packId,
|
|
137
|
+
packSize: String(packData.length),
|
|
138
|
+
indexSize: String(indexData.length),
|
|
139
|
+
objectCount: String(objectCount),
|
|
140
|
+
checksum: packChecksum,
|
|
141
|
+
createdAt: uploadedAt.toISOString()
|
|
142
|
+
};
|
|
143
|
+
// If skipAtomic is set, use the simple (non-atomic) upload path
|
|
144
|
+
if (options?.skipAtomic) {
|
|
145
|
+
const packKey = this._buildKey(`packs/${packId}.pack`);
|
|
146
|
+
await this._bucket.put(packKey, packData, { customMetadata: metadata });
|
|
147
|
+
const idxKey = this._buildKey(`packs/${packId}.idx`);
|
|
148
|
+
await this._bucket.put(idxKey, indexData, { customMetadata: metadata });
|
|
149
|
+
this._indexChecksums.set(packId, indexChecksum);
|
|
150
|
+
return {
|
|
151
|
+
packId,
|
|
152
|
+
packSize: packData.length,
|
|
153
|
+
indexSize: indexData.length,
|
|
154
|
+
checksum: packChecksum,
|
|
155
|
+
objectCount,
|
|
156
|
+
uploadedAt
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// Step 1: Upload to staging paths
|
|
160
|
+
const stagingPackKey = this._buildKey(`staging/${packId}.pack`);
|
|
161
|
+
const stagingIdxKey = this._buildKey(`staging/${packId}.idx`);
|
|
162
|
+
const manifestKey = this._buildKey(`packs/${packId}.manifest`);
|
|
163
|
+
try {
|
|
164
|
+
// Upload pack to staging
|
|
165
|
+
await this._bucket.put(stagingPackKey, packData, { customMetadata: metadata });
|
|
166
|
+
// Upload index to staging
|
|
167
|
+
await this._bucket.put(stagingIdxKey, indexData, { customMetadata: metadata });
|
|
168
|
+
// Step 2: Create manifest in 'staging' status
|
|
169
|
+
const manifest = {
|
|
170
|
+
version: 1,
|
|
171
|
+
packId,
|
|
172
|
+
packChecksum,
|
|
173
|
+
indexChecksum,
|
|
174
|
+
packSize: packData.length,
|
|
175
|
+
indexSize: indexData.length,
|
|
176
|
+
objectCount,
|
|
177
|
+
completedAt: uploadedAt.toISOString(),
|
|
178
|
+
status: 'staging'
|
|
179
|
+
};
|
|
180
|
+
await this._bucket.put(manifestKey, JSON.stringify(manifest), {
|
|
181
|
+
customMetadata: { packId, status: 'staging' }
|
|
182
|
+
});
|
|
183
|
+
// Step 3: Copy from staging to final location
|
|
184
|
+
const packKey = this._buildKey(`packs/${packId}.pack`);
|
|
185
|
+
const idxKey = this._buildKey(`packs/${packId}.idx`);
|
|
186
|
+
await this._bucket.put(packKey, packData, { customMetadata: metadata });
|
|
187
|
+
await this._bucket.put(idxKey, indexData, { customMetadata: metadata });
|
|
188
|
+
// Step 4: Update manifest to 'complete' status
|
|
189
|
+
manifest.status = 'complete';
|
|
190
|
+
await this._bucket.put(manifestKey, JSON.stringify(manifest), {
|
|
191
|
+
customMetadata: { packId, status: 'complete' }
|
|
192
|
+
});
|
|
193
|
+
// Step 5: Clean up staging files
|
|
194
|
+
await this._bucket.delete([stagingPackKey, stagingIdxKey]);
|
|
195
|
+
// Store index checksum for verification
|
|
196
|
+
this._indexChecksums.set(packId, indexChecksum);
|
|
197
|
+
return {
|
|
198
|
+
packId,
|
|
199
|
+
packSize: packData.length,
|
|
200
|
+
indexSize: indexData.length,
|
|
201
|
+
checksum: packChecksum,
|
|
202
|
+
objectCount,
|
|
203
|
+
uploadedAt
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
// Clean up any partial uploads on failure
|
|
208
|
+
try {
|
|
209
|
+
await this._bucket.delete([
|
|
210
|
+
stagingPackKey,
|
|
211
|
+
stagingIdxKey,
|
|
212
|
+
this._buildKey(`packs/${packId}.pack`),
|
|
213
|
+
this._buildKey(`packs/${packId}.idx`),
|
|
214
|
+
manifestKey
|
|
215
|
+
]);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Ignore cleanup errors
|
|
219
|
+
}
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Get the manifest for a packfile
|
|
225
|
+
*/
|
|
226
|
+
async getPackManifest(packId) {
|
|
227
|
+
const manifestKey = this._buildKey(`packs/${packId}.manifest`);
|
|
228
|
+
const manifestObj = await this._bucket.get(manifestKey);
|
|
229
|
+
if (!manifestObj) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const text = await manifestObj.text();
|
|
234
|
+
return JSON.parse(text);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Check if a packfile upload is complete
|
|
242
|
+
*
|
|
243
|
+
* A pack is considered complete if:
|
|
244
|
+
* 1. It has a manifest with status 'complete', OR
|
|
245
|
+
* 2. It was uploaded before the atomic upload feature (legacy packs without manifest)
|
|
246
|
+
* AND both .pack and .idx files exist
|
|
247
|
+
*/
|
|
248
|
+
async isPackComplete(packId) {
|
|
249
|
+
// Check for manifest first
|
|
250
|
+
const manifest = await this.getPackManifest(packId);
|
|
251
|
+
if (manifest) {
|
|
252
|
+
// If manifest exists, it must have 'complete' status
|
|
253
|
+
return manifest.status === 'complete';
|
|
254
|
+
}
|
|
255
|
+
// Legacy pack without manifest - check if both files exist
|
|
256
|
+
const packKey = this._buildKey(`packs/${packId}.pack`);
|
|
257
|
+
const idxKey = this._buildKey(`packs/${packId}.idx`);
|
|
258
|
+
const [packExists, idxExists] = await Promise.all([
|
|
259
|
+
this._bucket.head(packKey),
|
|
260
|
+
this._bucket.head(idxKey)
|
|
261
|
+
]);
|
|
262
|
+
return packExists !== null && idxExists !== null;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Download a packfile from R2
|
|
266
|
+
*/
|
|
267
|
+
async downloadPackfile(packId, options) {
|
|
268
|
+
// Verify pack completeness before downloading
|
|
269
|
+
const isComplete = await this.isPackComplete(packId);
|
|
270
|
+
if (!isComplete) {
|
|
271
|
+
if (options?.required) {
|
|
272
|
+
throw new R2PackError(`Packfile incomplete or not found: ${packId}`, 'NOT_FOUND', packId);
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const packKey = this._buildKey(`packs/${packId}.pack`);
|
|
277
|
+
const packObj = await this._bucket.get(packKey);
|
|
278
|
+
if (!packObj) {
|
|
279
|
+
if (options?.required) {
|
|
280
|
+
throw new R2PackError(`Packfile not found: ${packId}`, 'NOT_FOUND', packId);
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
let packData = new Uint8Array(await packObj.arrayBuffer());
|
|
285
|
+
// Verify checksum if requested (before byte range slicing)
|
|
286
|
+
if (options?.verify && !options?.byteRange) {
|
|
287
|
+
// Get stored checksum from metadata
|
|
288
|
+
const headObj = await this._bucket.head(packKey);
|
|
289
|
+
const storedChecksum = headObj?.customMetadata?.checksum;
|
|
290
|
+
if (storedChecksum) {
|
|
291
|
+
const computedChecksum = await computeChecksum(packData);
|
|
292
|
+
if (computedChecksum !== storedChecksum) {
|
|
293
|
+
throw new R2PackError(`Checksum mismatch for packfile: ${packId}`, 'CHECKSUM_MISMATCH', packId);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
// No stored checksum - data may have been corrupted/replaced
|
|
298
|
+
// Verify using the embedded pack checksum (last 20 bytes of packfile)
|
|
299
|
+
if (packData.length >= 20) {
|
|
300
|
+
const dataWithoutChecksum = packData.slice(0, packData.length - 20);
|
|
301
|
+
const computedChecksum = await computeChecksum(dataWithoutChecksum);
|
|
302
|
+
const embeddedChecksum = Array.from(packData.slice(packData.length - 20))
|
|
303
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
304
|
+
.join('');
|
|
305
|
+
if (computedChecksum !== embeddedChecksum) {
|
|
306
|
+
throw new R2PackError(`Checksum mismatch for packfile: ${packId}`, 'CHECKSUM_MISMATCH', packId);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
throw new R2PackError(`Packfile too small to verify: ${packId}`, 'CHECKSUM_MISMATCH', packId);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Handle byte range
|
|
315
|
+
if (options?.byteRange) {
|
|
316
|
+
const { start, end } = options.byteRange;
|
|
317
|
+
packData = packData.slice(start, end + 1);
|
|
318
|
+
}
|
|
319
|
+
const result = {
|
|
320
|
+
packData,
|
|
321
|
+
verified: options?.verify ? true : undefined
|
|
322
|
+
};
|
|
323
|
+
// Include index if requested
|
|
324
|
+
if (options?.includeIndex) {
|
|
325
|
+
const idxKey = this._buildKey(`packs/${packId}.idx`);
|
|
326
|
+
const idxObj = await this._bucket.get(idxKey);
|
|
327
|
+
if (idxObj) {
|
|
328
|
+
result.indexData = new Uint8Array(await idxObj.arrayBuffer());
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Get metadata for a packfile
|
|
335
|
+
*/
|
|
336
|
+
async getPackfileMetadata(packId) {
|
|
337
|
+
const packKey = this._buildKey(`packs/${packId}.pack`);
|
|
338
|
+
const headObj = await this._bucket.head(packKey);
|
|
339
|
+
if (!headObj) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
const meta = headObj.customMetadata || {};
|
|
343
|
+
return {
|
|
344
|
+
packId,
|
|
345
|
+
packSize: parseInt(meta.packSize || String(headObj.size), 10),
|
|
346
|
+
indexSize: parseInt(meta.indexSize || '0', 10),
|
|
347
|
+
objectCount: parseInt(meta.objectCount || '0', 10),
|
|
348
|
+
createdAt: new Date(meta.createdAt || Date.now()),
|
|
349
|
+
checksum: meta.checksum || ''
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* List all packfiles
|
|
354
|
+
*/
|
|
355
|
+
async listPackfiles(options) {
|
|
356
|
+
const prefix = this._buildKey('packs/');
|
|
357
|
+
const listResult = await this._bucket.list({ prefix, cursor: options?.cursor });
|
|
358
|
+
// Filter for .pack files only
|
|
359
|
+
let packFiles = listResult.objects.filter(obj => obj.key.endsWith('.pack'));
|
|
360
|
+
// Handle pagination with cursor (cursor is the index to start from)
|
|
361
|
+
let startIndex = 0;
|
|
362
|
+
if (options?.cursor) {
|
|
363
|
+
startIndex = parseInt(options.cursor, 10) || 0;
|
|
364
|
+
}
|
|
365
|
+
// Slice from cursor position
|
|
366
|
+
packFiles = packFiles.slice(startIndex);
|
|
367
|
+
// Apply limit
|
|
368
|
+
const hasLimit = options?.limit !== undefined && options.limit > 0;
|
|
369
|
+
const limitedPackFiles = hasLimit ? packFiles.slice(0, options.limit) : packFiles;
|
|
370
|
+
const items = [];
|
|
371
|
+
for (const obj of limitedPackFiles) {
|
|
372
|
+
// Extract packId from key
|
|
373
|
+
const match = obj.key.match(/([^/]+)\.pack$/);
|
|
374
|
+
if (match) {
|
|
375
|
+
const packId = match[1];
|
|
376
|
+
const metadata = await this.getPackfileMetadata(packId);
|
|
377
|
+
if (metadata) {
|
|
378
|
+
items.push(metadata);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// If no pagination options and no items, return a plain empty array
|
|
383
|
+
// This ensures toEqual([]) works as expected
|
|
384
|
+
if (items.length === 0 && !options?.limit && !options?.cursor) {
|
|
385
|
+
return [];
|
|
386
|
+
}
|
|
387
|
+
// Create a new array that also has ListPackfilesResult properties
|
|
388
|
+
const resultArray = [...items];
|
|
389
|
+
const result = resultArray;
|
|
390
|
+
result.items = items;
|
|
391
|
+
// Set cursor for next page if there are more items
|
|
392
|
+
if (hasLimit && packFiles.length > options.limit) {
|
|
393
|
+
result.cursor = String(startIndex + options.limit);
|
|
394
|
+
}
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Delete a packfile, its index, and manifest
|
|
399
|
+
*/
|
|
400
|
+
async deletePackfile(packId) {
|
|
401
|
+
const packKey = this._buildKey(`packs/${packId}.pack`);
|
|
402
|
+
const idxKey = this._buildKey(`packs/${packId}.idx`);
|
|
403
|
+
const manifestKey = this._buildKey(`packs/${packId}.manifest`);
|
|
404
|
+
// Check if pack exists
|
|
405
|
+
const exists = await this._bucket.head(packKey);
|
|
406
|
+
if (!exists) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
// Delete pack, index, and manifest atomically
|
|
410
|
+
await this._bucket.delete([packKey, idxKey, manifestKey]);
|
|
411
|
+
// Clear from index checksum cache
|
|
412
|
+
this._indexChecksums.delete(packId);
|
|
413
|
+
// Update multi-pack index if it exists
|
|
414
|
+
try {
|
|
415
|
+
const midx = await this.getMultiPackIndex();
|
|
416
|
+
if (midx.packIds.includes(packId)) {
|
|
417
|
+
// Rebuild without this pack
|
|
418
|
+
await this.rebuildMultiPackIndex();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// Ignore errors when updating multi-pack index
|
|
423
|
+
}
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Download just the index file for a packfile
|
|
428
|
+
*/
|
|
429
|
+
async downloadIndex(packId) {
|
|
430
|
+
const idxKey = this._buildKey(`packs/${packId}.idx`);
|
|
431
|
+
const idxObj = await this._bucket.get(idxKey);
|
|
432
|
+
if (!idxObj) {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
return new Uint8Array(await idxObj.arrayBuffer());
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Upload a new index for an existing packfile
|
|
439
|
+
*/
|
|
440
|
+
async uploadIndex(packId, indexData) {
|
|
441
|
+
// Check if pack exists
|
|
442
|
+
const packKey = this._buildKey(`packs/${packId}.pack`);
|
|
443
|
+
const exists = await this._bucket.head(packKey);
|
|
444
|
+
if (!exists) {
|
|
445
|
+
throw new R2PackError(`Packfile not found: ${packId}`, 'NOT_FOUND', packId);
|
|
446
|
+
}
|
|
447
|
+
// Upload new index
|
|
448
|
+
const idxKey = this._buildKey(`packs/${packId}.idx`);
|
|
449
|
+
await this._bucket.put(idxKey, indexData);
|
|
450
|
+
// Update checksum cache
|
|
451
|
+
const indexChecksum = await computeChecksum(indexData);
|
|
452
|
+
this._indexChecksums.set(packId, indexChecksum);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Verify that an index matches its packfile
|
|
456
|
+
*/
|
|
457
|
+
async verifyIndex(packId) {
|
|
458
|
+
// Get current index
|
|
459
|
+
const currentIndex = await this.downloadIndex(packId);
|
|
460
|
+
if (!currentIndex) {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
// Compare with stored checksum
|
|
464
|
+
const storedChecksum = this._indexChecksums.get(packId);
|
|
465
|
+
if (storedChecksum) {
|
|
466
|
+
const currentChecksum = await computeChecksum(currentIndex);
|
|
467
|
+
return currentChecksum === storedChecksum;
|
|
468
|
+
}
|
|
469
|
+
// If no stored checksum, consider it valid (basic check)
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Clean up orphaned staging files
|
|
474
|
+
*
|
|
475
|
+
* This should be called on startup to clean up any staging files
|
|
476
|
+
* left behind by failed uploads. It will:
|
|
477
|
+
* 1. List all files in the staging directory
|
|
478
|
+
* 2. For each pack ID found, check if it has a complete manifest
|
|
479
|
+
* 3. If not complete, delete the staging files and any partial final files
|
|
480
|
+
*
|
|
481
|
+
* @returns Array of pack IDs that were cleaned up
|
|
482
|
+
*/
|
|
483
|
+
async cleanupOrphanedStagingFiles() {
|
|
484
|
+
const stagingPrefix = this._buildKey('staging/');
|
|
485
|
+
const listResult = await this._bucket.list({ prefix: stagingPrefix });
|
|
486
|
+
// Extract unique pack IDs from staging files
|
|
487
|
+
const orphanedPackIds = new Set();
|
|
488
|
+
for (const obj of listResult.objects) {
|
|
489
|
+
// Extract pack ID from key like "staging/pack-xxx.pack" or "staging/pack-xxx.idx"
|
|
490
|
+
const match = obj.key.match(/staging\/([^/]+)\.(pack|idx)$/);
|
|
491
|
+
if (match) {
|
|
492
|
+
orphanedPackIds.add(match[1]);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const cleanedUp = [];
|
|
496
|
+
for (const packId of orphanedPackIds) {
|
|
497
|
+
// Check if this pack is complete
|
|
498
|
+
const isComplete = await this.isPackComplete(packId);
|
|
499
|
+
if (!isComplete) {
|
|
500
|
+
// Pack is incomplete - clean up all related files
|
|
501
|
+
const filesToDelete = [
|
|
502
|
+
this._buildKey(`staging/${packId}.pack`),
|
|
503
|
+
this._buildKey(`staging/${packId}.idx`),
|
|
504
|
+
this._buildKey(`packs/${packId}.pack`),
|
|
505
|
+
this._buildKey(`packs/${packId}.idx`),
|
|
506
|
+
this._buildKey(`packs/${packId}.manifest`)
|
|
507
|
+
];
|
|
508
|
+
try {
|
|
509
|
+
await this._bucket.delete(filesToDelete);
|
|
510
|
+
cleanedUp.push(packId);
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// Ignore errors during cleanup
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
// Pack is complete - just clean up staging files
|
|
518
|
+
const stagingFiles = [
|
|
519
|
+
this._buildKey(`staging/${packId}.pack`),
|
|
520
|
+
this._buildKey(`staging/${packId}.idx`)
|
|
521
|
+
];
|
|
522
|
+
try {
|
|
523
|
+
await this._bucket.delete(stagingFiles);
|
|
524
|
+
cleanedUp.push(packId);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
// Ignore errors during cleanup
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return cleanedUp;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Rebuild the multi-pack index from all packfiles
|
|
535
|
+
*/
|
|
536
|
+
async rebuildMultiPackIndex() {
|
|
537
|
+
// List all packs
|
|
538
|
+
const packs = await this.listPackfiles();
|
|
539
|
+
const packIds = packs.map(p => p.packId);
|
|
540
|
+
// Create entries for all objects in all packs
|
|
541
|
+
const entries = [];
|
|
542
|
+
for (let packIndex = 0; packIndex < packIds.length; packIndex++) {
|
|
543
|
+
const packId = packIds[packIndex];
|
|
544
|
+
// For now, create a synthetic entry per pack
|
|
545
|
+
// In a real implementation, we would parse the index file
|
|
546
|
+
const metadata = await this.getPackfileMetadata(packId);
|
|
547
|
+
if (metadata) {
|
|
548
|
+
// Create synthetic entries based on object count
|
|
549
|
+
for (let i = 0; i < metadata.objectCount; i++) {
|
|
550
|
+
// Generate synthetic object IDs based on pack checksum and index
|
|
551
|
+
const objectId = metadata.checksum.slice(0, 32) + i.toString(16).padStart(8, '0');
|
|
552
|
+
entries.push({
|
|
553
|
+
objectId,
|
|
554
|
+
packIndex,
|
|
555
|
+
offset: 12 + i * 100 // Synthetic offset
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// Sort entries by objectId for binary search
|
|
561
|
+
entries.sort((a, b) => a.objectId.localeCompare(b.objectId));
|
|
562
|
+
// Create multi-pack index
|
|
563
|
+
const midx = {
|
|
564
|
+
version: 1,
|
|
565
|
+
packIds,
|
|
566
|
+
entries,
|
|
567
|
+
checksum: new Uint8Array(20)
|
|
568
|
+
};
|
|
569
|
+
// Serialize and store
|
|
570
|
+
const serialized = serializeMultiPackIndex(midx);
|
|
571
|
+
const midxKey = this._buildKey('packs/multi-pack-index');
|
|
572
|
+
await this._bucket.put(midxKey, serialized);
|
|
573
|
+
// Update cache
|
|
574
|
+
this._midxCache = {
|
|
575
|
+
midx,
|
|
576
|
+
expiresAt: Date.now() + this._cacheTTL * 1000
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Get the current multi-pack index
|
|
581
|
+
*/
|
|
582
|
+
async getMultiPackIndex() {
|
|
583
|
+
// Check cache first
|
|
584
|
+
if (this._midxCache && this._midxCache.expiresAt > Date.now()) {
|
|
585
|
+
return this._midxCache.midx;
|
|
586
|
+
}
|
|
587
|
+
const midxKey = this._buildKey('packs/multi-pack-index');
|
|
588
|
+
const midxObj = await this._bucket.get(midxKey);
|
|
589
|
+
if (!midxObj) {
|
|
590
|
+
// Return empty index
|
|
591
|
+
return {
|
|
592
|
+
version: 1,
|
|
593
|
+
packIds: [],
|
|
594
|
+
entries: [],
|
|
595
|
+
checksum: new Uint8Array(20)
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
const data = new Uint8Array(await midxObj.arrayBuffer());
|
|
599
|
+
const midx = parseMultiPackIndex(data);
|
|
600
|
+
// Update cache
|
|
601
|
+
this._midxCache = {
|
|
602
|
+
midx,
|
|
603
|
+
expiresAt: Date.now() + this._cacheTTL * 1000
|
|
604
|
+
};
|
|
605
|
+
return midx;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Acquire a distributed lock on a resource using R2 conditional writes
|
|
609
|
+
* @param resource - Resource identifier to lock
|
|
610
|
+
* @param ttlMs - Time-to-live in milliseconds (default: 30000)
|
|
611
|
+
* @param holder - Optional identifier for the lock holder (for debugging)
|
|
612
|
+
* @returns LockHandle if acquired, null if lock is held by another process
|
|
613
|
+
*/
|
|
614
|
+
async acquireDistributedLock(resource, ttlMs = 30000, holder) {
|
|
615
|
+
const lockKey = this._buildKey(`locks/${resource}.lock`);
|
|
616
|
+
const now = Date.now();
|
|
617
|
+
const lockId = generateLockId();
|
|
618
|
+
const expiresAt = now + ttlMs;
|
|
619
|
+
const lockContent = {
|
|
620
|
+
lockId,
|
|
621
|
+
resource,
|
|
622
|
+
expiresAt,
|
|
623
|
+
acquiredAt: now,
|
|
624
|
+
holder
|
|
625
|
+
};
|
|
626
|
+
const lockData = new TextEncoder().encode(JSON.stringify(lockContent));
|
|
627
|
+
// Try to check if there's an existing lock
|
|
628
|
+
const existingObj = await this._bucket.head(lockKey);
|
|
629
|
+
if (existingObj) {
|
|
630
|
+
// Lock file exists, check if it's expired
|
|
631
|
+
const existingLockObj = await this._bucket.get(lockKey);
|
|
632
|
+
if (existingLockObj) {
|
|
633
|
+
try {
|
|
634
|
+
const existingContent = JSON.parse(new TextDecoder().decode(new Uint8Array(await existingLockObj.arrayBuffer())));
|
|
635
|
+
if (existingContent.expiresAt > now) {
|
|
636
|
+
// Lock is still valid, cannot acquire
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
// Lock is expired, try to overwrite with conditional write
|
|
640
|
+
// Use the existing etag to ensure atomicity
|
|
641
|
+
try {
|
|
642
|
+
await this._bucket.put(lockKey, lockData, {
|
|
643
|
+
onlyIf: { etagMatches: existingObj.etag }
|
|
644
|
+
});
|
|
645
|
+
// Get the new etag after successful write
|
|
646
|
+
const newObj = await this._bucket.head(lockKey);
|
|
647
|
+
if (!newObj) {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
resource,
|
|
652
|
+
lockId,
|
|
653
|
+
etag: newObj.etag,
|
|
654
|
+
expiresAt
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
// Conditional write failed - another process got the lock
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
// Failed to parse lock content, try to clean up and acquire
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// No existing lock, try to create new one with onlyIf condition
|
|
669
|
+
try {
|
|
670
|
+
// Use onlyIf with etagDoesNotMatch to ensure the object doesn't exist
|
|
671
|
+
// R2 will fail if object already exists when we use this condition
|
|
672
|
+
await this._bucket.put(lockKey, lockData, {
|
|
673
|
+
onlyIf: { etagDoesNotMatch: '*' }
|
|
674
|
+
});
|
|
675
|
+
// Get the etag of the newly created lock
|
|
676
|
+
const newObj = await this._bucket.head(lockKey);
|
|
677
|
+
if (!newObj) {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
// Verify we actually own this lock by checking the lockId
|
|
681
|
+
const verifyObj = await this._bucket.get(lockKey);
|
|
682
|
+
if (verifyObj) {
|
|
683
|
+
const content = JSON.parse(new TextDecoder().decode(new Uint8Array(await verifyObj.arrayBuffer())));
|
|
684
|
+
if (content.lockId !== lockId) {
|
|
685
|
+
// Another process created the lock
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
resource,
|
|
691
|
+
lockId,
|
|
692
|
+
etag: newObj.etag,
|
|
693
|
+
expiresAt
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
// Failed to create lock - likely another process created it first
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Release a distributed lock
|
|
703
|
+
* @param handle - Lock handle returned from acquireDistributedLock
|
|
704
|
+
*/
|
|
705
|
+
async releaseDistributedLock(handle) {
|
|
706
|
+
const lockKey = this._buildKey(`locks/${handle.resource}.lock`);
|
|
707
|
+
// Verify we still own the lock before deleting
|
|
708
|
+
const existingObj = await this._bucket.get(lockKey);
|
|
709
|
+
if (existingObj) {
|
|
710
|
+
try {
|
|
711
|
+
const content = JSON.parse(new TextDecoder().decode(new Uint8Array(await existingObj.arrayBuffer())));
|
|
712
|
+
// Only delete if we own this lock (matching lockId)
|
|
713
|
+
if (content.lockId === handle.lockId) {
|
|
714
|
+
await this._bucket.delete(lockKey);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
// Failed to parse, don't delete to avoid corrupting another process's lock
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Refresh a distributed lock to extend its TTL
|
|
724
|
+
* @param handle - Lock handle to refresh
|
|
725
|
+
* @param ttlMs - New TTL in milliseconds (default: 30000)
|
|
726
|
+
* @returns true if refresh succeeded, false if lock was lost
|
|
727
|
+
*/
|
|
728
|
+
async refreshDistributedLock(handle, ttlMs = 30000) {
|
|
729
|
+
const lockKey = this._buildKey(`locks/${handle.resource}.lock`);
|
|
730
|
+
const now = Date.now();
|
|
731
|
+
const newExpiresAt = now + ttlMs;
|
|
732
|
+
// Get current lock to verify ownership
|
|
733
|
+
const existingObj = await this._bucket.head(lockKey);
|
|
734
|
+
if (!existingObj) {
|
|
735
|
+
return false; // Lock doesn't exist
|
|
736
|
+
}
|
|
737
|
+
const existingLockObj = await this._bucket.get(lockKey);
|
|
738
|
+
if (!existingLockObj) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
const existingContent = JSON.parse(new TextDecoder().decode(new Uint8Array(await existingLockObj.arrayBuffer())));
|
|
743
|
+
// Verify we own this lock
|
|
744
|
+
if (existingContent.lockId !== handle.lockId) {
|
|
745
|
+
return false; // We don't own this lock
|
|
746
|
+
}
|
|
747
|
+
// Create updated lock content
|
|
748
|
+
const updatedContent = {
|
|
749
|
+
...existingContent,
|
|
750
|
+
expiresAt: newExpiresAt
|
|
751
|
+
};
|
|
752
|
+
const lockData = new TextEncoder().encode(JSON.stringify(updatedContent));
|
|
753
|
+
// Update with conditional write using etag
|
|
754
|
+
try {
|
|
755
|
+
await this._bucket.put(lockKey, lockData, {
|
|
756
|
+
onlyIf: { etagMatches: existingObj.etag }
|
|
757
|
+
});
|
|
758
|
+
// Update the handle's expiration and etag
|
|
759
|
+
const newObj = await this._bucket.head(lockKey);
|
|
760
|
+
if (newObj) {
|
|
761
|
+
handle.etag = newObj.etag;
|
|
762
|
+
handle.expiresAt = newExpiresAt;
|
|
763
|
+
}
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
// Conditional write failed - lock was modified
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Clean up expired locks from R2 storage
|
|
777
|
+
* This should be called periodically to remove stale lock files
|
|
778
|
+
* @returns Number of locks cleaned up
|
|
779
|
+
*/
|
|
780
|
+
async cleanupExpiredLocks() {
|
|
781
|
+
const prefix = this._buildKey('locks/');
|
|
782
|
+
const listResult = await this._bucket.list({ prefix });
|
|
783
|
+
const now = Date.now();
|
|
784
|
+
let cleanedCount = 0;
|
|
785
|
+
for (const obj of listResult.objects) {
|
|
786
|
+
if (!obj.key.endsWith('.lock'))
|
|
787
|
+
continue;
|
|
788
|
+
const lockObj = await this._bucket.get(obj.key);
|
|
789
|
+
if (lockObj) {
|
|
790
|
+
try {
|
|
791
|
+
const content = JSON.parse(new TextDecoder().decode(new Uint8Array(await lockObj.arrayBuffer())));
|
|
792
|
+
if (content.expiresAt <= now) {
|
|
793
|
+
// Lock is expired, safe to delete
|
|
794
|
+
await this._bucket.delete(obj.key);
|
|
795
|
+
cleanedCount++;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
// Invalid lock file, delete it
|
|
800
|
+
await this._bucket.delete(obj.key);
|
|
801
|
+
cleanedCount++;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return cleanedCount;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Acquire a lock on a packfile (backward-compatible wrapper)
|
|
809
|
+
* Uses distributed locking with R2 conditional writes
|
|
810
|
+
*/
|
|
811
|
+
async acquireLock(packId, options) {
|
|
812
|
+
const ttl = options?.ttl ?? 30000; // Default 30 second TTL
|
|
813
|
+
const timeout = options?.timeout ?? 0;
|
|
814
|
+
const startTime = Date.now();
|
|
815
|
+
// Try to acquire the distributed lock
|
|
816
|
+
let handle = await this.acquireDistributedLock(packId, ttl, options?.holder);
|
|
817
|
+
// If timeout is specified, retry until timeout expires
|
|
818
|
+
if (!handle && timeout > 0) {
|
|
819
|
+
while (Date.now() - startTime < timeout) {
|
|
820
|
+
await new Promise(resolve => setTimeout(resolve, 50)); // Wait 50ms between retries
|
|
821
|
+
handle = await this.acquireDistributedLock(packId, ttl, options?.holder);
|
|
822
|
+
if (handle)
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
if (!handle) {
|
|
827
|
+
if (timeout > 0) {
|
|
828
|
+
throw new R2PackError(`Lock timeout for packfile: ${packId}`, 'LOCKED', packId);
|
|
829
|
+
}
|
|
830
|
+
throw new R2PackError(`Packfile is locked: ${packId}`, 'LOCKED', packId);
|
|
831
|
+
}
|
|
832
|
+
// Create the PackLock interface with distributed lock backing
|
|
833
|
+
const self = this;
|
|
834
|
+
let released = false;
|
|
835
|
+
return {
|
|
836
|
+
packId,
|
|
837
|
+
handle,
|
|
838
|
+
isHeld: () => !released && handle.expiresAt > Date.now(),
|
|
839
|
+
release: async () => {
|
|
840
|
+
if (!released && handle) {
|
|
841
|
+
await self.releaseDistributedLock(handle);
|
|
842
|
+
released = true;
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
refresh: async () => {
|
|
846
|
+
if (released || !handle)
|
|
847
|
+
return false;
|
|
848
|
+
return await self.refreshDistributedLock(handle, ttl);
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Serialize a multi-pack index to bytes
|
|
855
|
+
*/
|
|
856
|
+
function serializeMultiPackIndex(midx) {
|
|
857
|
+
// Calculate size
|
|
858
|
+
// Header: 4 (signature) + 4 (version) + 4 (packCount) + 4 (entryCount) = 16
|
|
859
|
+
// Pack IDs: packCount * (4 + packId.length) each with length prefix
|
|
860
|
+
// Entries: entryCount * (40 + 4 + 8) = 52 bytes each (objectId + packIndex + offset)
|
|
861
|
+
// Checksum: 20
|
|
862
|
+
let packIdsSize = 0;
|
|
863
|
+
for (const packId of midx.packIds) {
|
|
864
|
+
packIdsSize += 4 + new TextEncoder().encode(packId).length;
|
|
865
|
+
}
|
|
866
|
+
const entriesSize = midx.entries.length * 52;
|
|
867
|
+
const totalSize = 16 + packIdsSize + entriesSize + 20;
|
|
868
|
+
const data = new Uint8Array(totalSize);
|
|
869
|
+
const view = new DataView(data.buffer);
|
|
870
|
+
let offset = 0;
|
|
871
|
+
// Signature: MIDX
|
|
872
|
+
data.set(MIDX_SIGNATURE, offset);
|
|
873
|
+
offset += 4;
|
|
874
|
+
// Version
|
|
875
|
+
view.setUint32(offset, midx.version, false);
|
|
876
|
+
offset += 4;
|
|
877
|
+
// Pack count
|
|
878
|
+
view.setUint32(offset, midx.packIds.length, false);
|
|
879
|
+
offset += 4;
|
|
880
|
+
// Entry count
|
|
881
|
+
view.setUint32(offset, midx.entries.length, false);
|
|
882
|
+
offset += 4;
|
|
883
|
+
// Pack IDs
|
|
884
|
+
const encoder = new TextEncoder();
|
|
885
|
+
for (const packId of midx.packIds) {
|
|
886
|
+
const encoded = encoder.encode(packId);
|
|
887
|
+
view.setUint32(offset, encoded.length, false);
|
|
888
|
+
offset += 4;
|
|
889
|
+
data.set(encoded, offset);
|
|
890
|
+
offset += encoded.length;
|
|
891
|
+
}
|
|
892
|
+
// Entries
|
|
893
|
+
for (const entry of midx.entries) {
|
|
894
|
+
// Object ID (40 hex chars = 20 bytes as hex string, store as 40 bytes)
|
|
895
|
+
const objIdBytes = encoder.encode(entry.objectId.padEnd(40, '0').slice(0, 40));
|
|
896
|
+
data.set(objIdBytes, offset);
|
|
897
|
+
offset += 40;
|
|
898
|
+
// Pack index
|
|
899
|
+
view.setUint32(offset, entry.packIndex, false);
|
|
900
|
+
offset += 4;
|
|
901
|
+
// Offset (as 64-bit, but we use 32-bit high + 32-bit low)
|
|
902
|
+
view.setUint32(offset, 0, false); // high bits
|
|
903
|
+
offset += 4;
|
|
904
|
+
view.setUint32(offset, entry.offset, false); // low bits
|
|
905
|
+
offset += 4;
|
|
906
|
+
}
|
|
907
|
+
// Checksum
|
|
908
|
+
data.set(midx.checksum.slice(0, 20), offset);
|
|
909
|
+
return data;
|
|
910
|
+
}
|
|
911
|
+
// Standalone functions
|
|
912
|
+
/**
|
|
913
|
+
* Upload a packfile to R2
|
|
914
|
+
*/
|
|
915
|
+
export async function uploadPackfile(bucket, packData, indexData, options) {
|
|
916
|
+
const storage = new R2PackStorage({ bucket, prefix: options?.prefix });
|
|
917
|
+
return storage.uploadPackfile(packData, indexData);
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Download a packfile from R2
|
|
921
|
+
*/
|
|
922
|
+
export async function downloadPackfile(bucket, packId, options) {
|
|
923
|
+
const storage = new R2PackStorage({ bucket, prefix: options?.prefix });
|
|
924
|
+
return storage.downloadPackfile(packId, options);
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Get packfile metadata
|
|
928
|
+
*/
|
|
929
|
+
export async function getPackfileMetadata(bucket, packId, options) {
|
|
930
|
+
const storage = new R2PackStorage({ bucket, prefix: options?.prefix });
|
|
931
|
+
return storage.getPackfileMetadata(packId);
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* List all packfiles
|
|
935
|
+
*/
|
|
936
|
+
export async function listPackfiles(bucket, options) {
|
|
937
|
+
const storage = new R2PackStorage({ bucket, prefix: options?.prefix });
|
|
938
|
+
const result = await storage.listPackfiles({ limit: options?.limit, cursor: options?.cursor });
|
|
939
|
+
return result.items;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Delete a packfile
|
|
943
|
+
*/
|
|
944
|
+
export async function deletePackfile(bucket, packId, options) {
|
|
945
|
+
const storage = new R2PackStorage({ bucket, prefix: options?.prefix });
|
|
946
|
+
return storage.deletePackfile(packId);
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Create a multi-pack index from all packfiles in the bucket
|
|
950
|
+
*/
|
|
951
|
+
export async function createMultiPackIndex(bucket, options) {
|
|
952
|
+
const storage = new R2PackStorage({ bucket, prefix: options?.prefix });
|
|
953
|
+
await storage.rebuildMultiPackIndex();
|
|
954
|
+
return storage.getMultiPackIndex();
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Parse a multi-pack index from raw bytes
|
|
958
|
+
*/
|
|
959
|
+
export function parseMultiPackIndex(data) {
|
|
960
|
+
if (data.length < 16) {
|
|
961
|
+
throw new R2PackError('Multi-pack index too small', 'INVALID_DATA');
|
|
962
|
+
}
|
|
963
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
964
|
+
let offset = 0;
|
|
965
|
+
// Check signature
|
|
966
|
+
for (let i = 0; i < 4; i++) {
|
|
967
|
+
if (data[i] !== MIDX_SIGNATURE[i]) {
|
|
968
|
+
throw new R2PackError('Invalid multi-pack index signature', 'INVALID_DATA');
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
offset += 4;
|
|
972
|
+
// Version
|
|
973
|
+
const version = view.getUint32(offset, false);
|
|
974
|
+
offset += 4;
|
|
975
|
+
// Pack count
|
|
976
|
+
const packCount = view.getUint32(offset, false);
|
|
977
|
+
offset += 4;
|
|
978
|
+
// Entry count
|
|
979
|
+
const entryCount = view.getUint32(offset, false);
|
|
980
|
+
offset += 4;
|
|
981
|
+
// Read pack IDs
|
|
982
|
+
const decoder = new TextDecoder();
|
|
983
|
+
const packIds = [];
|
|
984
|
+
for (let i = 0; i < packCount; i++) {
|
|
985
|
+
const len = view.getUint32(offset, false);
|
|
986
|
+
offset += 4;
|
|
987
|
+
const packIdBytes = data.slice(offset, offset + len);
|
|
988
|
+
packIds.push(decoder.decode(packIdBytes));
|
|
989
|
+
offset += len;
|
|
990
|
+
}
|
|
991
|
+
// Read entries
|
|
992
|
+
const entries = [];
|
|
993
|
+
for (let i = 0; i < entryCount; i++) {
|
|
994
|
+
const objectIdBytes = data.slice(offset, offset + 40);
|
|
995
|
+
const objectId = decoder.decode(objectIdBytes);
|
|
996
|
+
offset += 40;
|
|
997
|
+
const packIndex = view.getUint32(offset, false);
|
|
998
|
+
offset += 4;
|
|
999
|
+
// Skip high bits
|
|
1000
|
+
offset += 4;
|
|
1001
|
+
const entryOffset = view.getUint32(offset, false);
|
|
1002
|
+
offset += 4;
|
|
1003
|
+
entries.push({
|
|
1004
|
+
objectId,
|
|
1005
|
+
packIndex,
|
|
1006
|
+
offset: entryOffset
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
// Read checksum
|
|
1010
|
+
const checksum = data.slice(offset, offset + 20);
|
|
1011
|
+
return {
|
|
1012
|
+
version,
|
|
1013
|
+
packIds,
|
|
1014
|
+
entries,
|
|
1015
|
+
checksum: new Uint8Array(checksum)
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Look up an object in the multi-pack index using binary search
|
|
1020
|
+
*/
|
|
1021
|
+
export function lookupObjectInMultiPack(midx, objectId) {
|
|
1022
|
+
const entries = midx.entries;
|
|
1023
|
+
if (entries.length === 0) {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
// Binary search
|
|
1027
|
+
let left = 0;
|
|
1028
|
+
let right = entries.length - 1;
|
|
1029
|
+
while (left <= right) {
|
|
1030
|
+
const mid = Math.floor((left + right) / 2);
|
|
1031
|
+
const entry = entries[mid];
|
|
1032
|
+
const cmp = objectId.localeCompare(entry.objectId);
|
|
1033
|
+
if (cmp === 0) {
|
|
1034
|
+
return entry;
|
|
1035
|
+
}
|
|
1036
|
+
else if (cmp < 0) {
|
|
1037
|
+
right = mid - 1;
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
left = mid + 1;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Acquire a lock on a packfile
|
|
1047
|
+
*/
|
|
1048
|
+
export async function acquirePackLock(bucket, packId, options) {
|
|
1049
|
+
const storage = new R2PackStorage({ bucket, prefix: options?.prefix });
|
|
1050
|
+
return storage.acquireLock(packId, options);
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Release a lock on a packfile
|
|
1054
|
+
* Note: This function requires a valid PackLock with a handle to properly release distributed locks
|
|
1055
|
+
*/
|
|
1056
|
+
export async function releasePackLock(bucket, packId, options) {
|
|
1057
|
+
// For backward compatibility, we just delete the lock file directly
|
|
1058
|
+
// This is less safe than using the handle-based release, but works for simple cases
|
|
1059
|
+
const lockKey = buildKey(options?.prefix ?? '', `locks/${packId}.lock`);
|
|
1060
|
+
await bucket.delete(lockKey);
|
|
1061
|
+
}
|
|
1062
|
+
//# sourceMappingURL=r2-pack.js.map
|