@felipedsvit/s3node 0.1.2
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 +143 -0
- package/bin/s3node.js +101 -0
- package/docs/plan.md +340 -0
- package/package.json +50 -0
- package/src/auth/chunked.js +277 -0
- package/src/auth/credentials.js +41 -0
- package/src/auth/sigv4.js +293 -0
- package/src/errors.js +65 -0
- package/src/features/cors.js +115 -0
- package/src/features/encryption.js +201 -0
- package/src/features/formdata.js +169 -0
- package/src/features/lifecycle.js +175 -0
- package/src/features/notifications.js +161 -0
- package/src/features/policy.js +0 -0
- package/src/features/postpolicy.js +150 -0
- package/src/features/tagging.js +64 -0
- package/src/handlers/bucket.js +197 -0
- package/src/handlers/config.js +129 -0
- package/src/handlers/index.js +6 -0
- package/src/handlers/multipart.js +109 -0
- package/src/handlers/object.js +211 -0
- package/src/handlers/post.js +106 -0
- package/src/handlers/service.js +10 -0
- package/src/handlers/shared.js +84 -0
- package/src/http.js +243 -0
- package/src/index.js +30 -0
- package/src/router.js +128 -0
- package/src/server.js +282 -0
- package/src/storage/blobs.js +135 -0
- package/src/storage/metadata.js +616 -0
- package/src/storage/store.js +548 -0
- package/src/util/bytes.js +35 -0
- package/src/util/crc.js +54 -0
- package/src/util/hash.js +64 -0
- package/src/xml.js +205 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
2
|
+
import { keySuccessor, prefixUpperBound, toKeyBuffer } from '../util/bytes.js'
|
|
3
|
+
|
|
4
|
+
export const SCHEMA_VERSION = 2
|
|
5
|
+
|
|
6
|
+
export const NULL_VERSION = 'null'
|
|
7
|
+
|
|
8
|
+
const SCHEMA = `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS buckets (
|
|
10
|
+
name TEXT PRIMARY KEY,
|
|
11
|
+
created_at INTEGER NOT NULL,
|
|
12
|
+
region TEXT NOT NULL
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS bucket_config (
|
|
16
|
+
bucket TEXT NOT NULL,
|
|
17
|
+
name TEXT NOT NULL,
|
|
18
|
+
value TEXT NOT NULL,
|
|
19
|
+
PRIMARY KEY (bucket, name)
|
|
20
|
+
) WITHOUT ROWID;
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS objects (
|
|
23
|
+
bucket TEXT NOT NULL,
|
|
24
|
+
key BLOB NOT NULL,
|
|
25
|
+
version_id TEXT NOT NULL,
|
|
26
|
+
sequence INTEGER NOT NULL,
|
|
27
|
+
is_latest INTEGER NOT NULL DEFAULT 1,
|
|
28
|
+
is_delete_marker INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
size INTEGER NOT NULL,
|
|
30
|
+
etag TEXT NOT NULL,
|
|
31
|
+
content_type TEXT,
|
|
32
|
+
last_modified INTEGER NOT NULL,
|
|
33
|
+
blob_id TEXT,
|
|
34
|
+
parts TEXT,
|
|
35
|
+
metadata TEXT,
|
|
36
|
+
checksums TEXT,
|
|
37
|
+
tags TEXT,
|
|
38
|
+
encryption TEXT,
|
|
39
|
+
PRIMARY KEY (bucket, key, version_id)
|
|
40
|
+
) WITHOUT ROWID;
|
|
41
|
+
|
|
42
|
+
-- Current-version listings are the hot path; the partial index keeps them from
|
|
43
|
+
-- walking historical versions.
|
|
44
|
+
CREATE INDEX IF NOT EXISTS objects_latest ON objects (bucket, key) WHERE is_latest = 1;
|
|
45
|
+
CREATE INDEX IF NOT EXISTS objects_sequence ON objects (bucket, key, sequence DESC);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS uploads (
|
|
48
|
+
upload_id TEXT PRIMARY KEY,
|
|
49
|
+
bucket TEXT NOT NULL,
|
|
50
|
+
key BLOB NOT NULL,
|
|
51
|
+
initiated_at INTEGER NOT NULL,
|
|
52
|
+
content_type TEXT,
|
|
53
|
+
metadata TEXT,
|
|
54
|
+
tags TEXT,
|
|
55
|
+
encryption TEXT
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE INDEX IF NOT EXISTS uploads_by_bucket_key ON uploads (bucket, key);
|
|
59
|
+
|
|
60
|
+
CREATE TABLE IF NOT EXISTS upload_parts (
|
|
61
|
+
upload_id TEXT NOT NULL,
|
|
62
|
+
part_number INTEGER NOT NULL,
|
|
63
|
+
size INTEGER NOT NULL,
|
|
64
|
+
etag TEXT NOT NULL,
|
|
65
|
+
blob_id TEXT NOT NULL,
|
|
66
|
+
uploaded_at INTEGER NOT NULL,
|
|
67
|
+
PRIMARY KEY (upload_id, part_number)
|
|
68
|
+
) WITHOUT ROWID;
|
|
69
|
+
`
|
|
70
|
+
|
|
71
|
+
const LIST_BATCH = 512
|
|
72
|
+
|
|
73
|
+
function decodeRow(row) {
|
|
74
|
+
if (!row) return null
|
|
75
|
+
return {
|
|
76
|
+
bucket: row.bucket,
|
|
77
|
+
key: Buffer.from(row.key),
|
|
78
|
+
versionId: row.version_id,
|
|
79
|
+
sequence: row.sequence,
|
|
80
|
+
isLatest: row.is_latest === 1,
|
|
81
|
+
isDeleteMarker: row.is_delete_marker === 1,
|
|
82
|
+
size: row.size,
|
|
83
|
+
etag: row.etag,
|
|
84
|
+
contentType: row.content_type ?? undefined,
|
|
85
|
+
lastModified: new Date(row.last_modified),
|
|
86
|
+
blobId: row.blob_id ?? null,
|
|
87
|
+
parts: row.parts ? JSON.parse(row.parts) : null,
|
|
88
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : {},
|
|
89
|
+
checksums: row.checksums ? JSON.parse(row.checksums) : {},
|
|
90
|
+
tags: row.tags ? JSON.parse(row.tags) : {},
|
|
91
|
+
encryption: row.encryption ? JSON.parse(row.encryption) : null,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Rebuilds a v1 `objects` table into the versioned v2 shape. SQLite cannot
|
|
97
|
+
* change a primary key in place, so the table is recreated and copied with
|
|
98
|
+
* every existing row becoming the `null` version.
|
|
99
|
+
*/
|
|
100
|
+
function migrateV1ToV2(db) {
|
|
101
|
+
const columns = db.prepare('PRAGMA table_info(objects)').all().map((c) => c.name)
|
|
102
|
+
if (columns.length === 0 || columns.includes('version_id')) return
|
|
103
|
+
db.exec('ALTER TABLE objects RENAME TO objects_v1')
|
|
104
|
+
db.exec(SCHEMA)
|
|
105
|
+
db.exec(`
|
|
106
|
+
INSERT INTO objects (bucket, key, version_id, sequence, is_latest, is_delete_marker,
|
|
107
|
+
size, etag, content_type, last_modified, blob_id, parts, metadata, checksums)
|
|
108
|
+
SELECT bucket, key, 'null', last_modified, 1, 0,
|
|
109
|
+
size, etag, content_type, last_modified, blob_id, parts, metadata, checksums
|
|
110
|
+
FROM objects_v1`)
|
|
111
|
+
db.exec('DROP TABLE objects_v1')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Metadata lives in SQLite because ListObjectsV2 is an ordered range scan.
|
|
116
|
+
* A filesystem walk is unordered and O(n) over the whole bucket; the primary
|
|
117
|
+
* key index makes it O(log n + k). Storing the key as BLOB additionally gives
|
|
118
|
+
* byte-wise ordering for free (docs/plan.md section 8).
|
|
119
|
+
*/
|
|
120
|
+
export class MetadataStore {
|
|
121
|
+
constructor(path) {
|
|
122
|
+
this.db = new DatabaseSync(path)
|
|
123
|
+
this.db.exec('PRAGMA journal_mode = WAL')
|
|
124
|
+
this.db.exec('PRAGMA synchronous = NORMAL')
|
|
125
|
+
this.db.exec('PRAGMA busy_timeout = 5000')
|
|
126
|
+
this.db.exec('PRAGMA foreign_keys = ON')
|
|
127
|
+
|
|
128
|
+
migrateV1ToV2(this.db)
|
|
129
|
+
this.db.exec(SCHEMA)
|
|
130
|
+
this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
|
|
131
|
+
|
|
132
|
+
this.statements = {
|
|
133
|
+
createBucket: this.db.prepare('INSERT INTO buckets (name, created_at, region) VALUES (?, ?, ?)'),
|
|
134
|
+
getBucket: this.db.prepare('SELECT name, created_at, region FROM buckets WHERE name = ?'),
|
|
135
|
+
listBuckets: this.db.prepare('SELECT name, created_at, region FROM buckets ORDER BY name ASC'),
|
|
136
|
+
deleteBucket: this.db.prepare('DELETE FROM buckets WHERE name = ?'),
|
|
137
|
+
countObjects: this.db.prepare('SELECT count(*) AS total FROM objects WHERE bucket = ?'),
|
|
138
|
+
|
|
139
|
+
getConfig: this.db.prepare('SELECT value FROM bucket_config WHERE bucket = ? AND name = ?'),
|
|
140
|
+
putConfig: this.db.prepare(
|
|
141
|
+
'INSERT INTO bucket_config (bucket, name, value) VALUES (?, ?, ?) ' +
|
|
142
|
+
'ON CONFLICT (bucket, name) DO UPDATE SET value = excluded.value'),
|
|
143
|
+
deleteConfig: this.db.prepare('DELETE FROM bucket_config WHERE bucket = ? AND name = ?'),
|
|
144
|
+
deleteAllConfig: this.db.prepare('DELETE FROM bucket_config WHERE bucket = ?'),
|
|
145
|
+
bucketsWithConfig: this.db.prepare('SELECT bucket, value FROM bucket_config WHERE name = ?'),
|
|
146
|
+
|
|
147
|
+
putObject: this.db.prepare(`
|
|
148
|
+
INSERT INTO objects (bucket, key, version_id, sequence, is_latest, is_delete_marker,
|
|
149
|
+
size, etag, content_type, last_modified, blob_id, parts,
|
|
150
|
+
metadata, checksums, tags, encryption)
|
|
151
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
152
|
+
ON CONFLICT (bucket, key, version_id) DO UPDATE SET
|
|
153
|
+
sequence = excluded.sequence, is_latest = excluded.is_latest,
|
|
154
|
+
is_delete_marker = excluded.is_delete_marker, size = excluded.size,
|
|
155
|
+
etag = excluded.etag, content_type = excluded.content_type,
|
|
156
|
+
last_modified = excluded.last_modified, blob_id = excluded.blob_id,
|
|
157
|
+
parts = excluded.parts, metadata = excluded.metadata,
|
|
158
|
+
checksums = excluded.checksums, tags = excluded.tags, encryption = excluded.encryption`),
|
|
159
|
+
getVersion: this.db.prepare('SELECT * FROM objects WHERE bucket = ? AND key = ? AND version_id = ?'),
|
|
160
|
+
getLatest: this.db.prepare(
|
|
161
|
+
'SELECT * FROM objects WHERE bucket = ? AND key = ? AND is_latest = 1'),
|
|
162
|
+
allVersionsOfKey: this.db.prepare(
|
|
163
|
+
'SELECT * FROM objects WHERE bucket = ? AND key = ? ORDER BY sequence DESC'),
|
|
164
|
+
clearLatest: this.db.prepare(
|
|
165
|
+
'UPDATE objects SET is_latest = 0 WHERE bucket = ? AND key = ? AND is_latest = 1'),
|
|
166
|
+
promoteLatest: this.db.prepare(`
|
|
167
|
+
UPDATE objects SET is_latest = 1
|
|
168
|
+
WHERE bucket = ? AND key = ? AND version_id = (
|
|
169
|
+
SELECT version_id FROM objects WHERE bucket = ? AND key = ?
|
|
170
|
+
ORDER BY sequence DESC LIMIT 1)`),
|
|
171
|
+
deleteVersion: this.db.prepare(
|
|
172
|
+
'DELETE FROM objects WHERE bucket = ? AND key = ? AND version_id = ?'),
|
|
173
|
+
updateTags: this.db.prepare(
|
|
174
|
+
'UPDATE objects SET tags = ? WHERE bucket = ? AND key = ? AND version_id = ?'),
|
|
175
|
+
maxSequence: this.db.prepare('SELECT IFNULL(MAX(sequence), 0) AS value FROM objects'),
|
|
176
|
+
|
|
177
|
+
scanLatest: this.db.prepare(`
|
|
178
|
+
SELECT * FROM objects
|
|
179
|
+
WHERE bucket = ? AND key >= ? AND is_latest = 1 AND is_delete_marker = 0
|
|
180
|
+
ORDER BY key ASC LIMIT ?`),
|
|
181
|
+
scanLatestRange: this.db.prepare(`
|
|
182
|
+
SELECT * FROM objects
|
|
183
|
+
WHERE bucket = ? AND key >= ? AND key < ? AND is_latest = 1 AND is_delete_marker = 0
|
|
184
|
+
ORDER BY key ASC LIMIT ?`),
|
|
185
|
+
// Version listings page on (key, sequence): a batch boundary may land in
|
|
186
|
+
// the middle of one key's versions, so a key-only cursor would drop the
|
|
187
|
+
// remainder of that key.
|
|
188
|
+
scanVersions: this.db.prepare(`
|
|
189
|
+
SELECT * FROM objects
|
|
190
|
+
WHERE bucket = ? AND (key > ? OR (key = ? AND sequence < ?))
|
|
191
|
+
ORDER BY key ASC, sequence DESC LIMIT ?`),
|
|
192
|
+
scanVersionsRange: this.db.prepare(`
|
|
193
|
+
SELECT * FROM objects
|
|
194
|
+
WHERE bucket = ? AND (key > ? OR (key = ? AND sequence < ?)) AND key < ?
|
|
195
|
+
ORDER BY key ASC, sequence DESC LIMIT ?`),
|
|
196
|
+
allObjects: this.db.prepare('SELECT blob_id, parts FROM objects WHERE bucket = ?'),
|
|
197
|
+
deleteAllObjects: this.db.prepare('DELETE FROM objects WHERE bucket = ?'),
|
|
198
|
+
expiredObjects: this.db.prepare(`
|
|
199
|
+
SELECT * FROM objects WHERE bucket = ? AND last_modified < ? ORDER BY key ASC`),
|
|
200
|
+
|
|
201
|
+
createUpload: this.db.prepare(
|
|
202
|
+
'INSERT INTO uploads (upload_id, bucket, key, initiated_at, content_type, metadata, tags, encryption) ' +
|
|
203
|
+
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'),
|
|
204
|
+
getUpload: this.db.prepare('SELECT * FROM uploads WHERE upload_id = ?'),
|
|
205
|
+
deleteUpload: this.db.prepare('DELETE FROM uploads WHERE upload_id = ?'),
|
|
206
|
+
listUploads: this.db.prepare(
|
|
207
|
+
'SELECT * FROM uploads WHERE bucket = ? ORDER BY key ASC, upload_id ASC LIMIT ?'),
|
|
208
|
+
staleUploads: this.db.prepare('SELECT * FROM uploads WHERE bucket = ? AND initiated_at < ?'),
|
|
209
|
+
|
|
210
|
+
putPart: this.db.prepare(`
|
|
211
|
+
INSERT INTO upload_parts (upload_id, part_number, size, etag, blob_id, uploaded_at)
|
|
212
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
213
|
+
ON CONFLICT (upload_id, part_number) DO UPDATE SET
|
|
214
|
+
size = excluded.size, etag = excluded.etag,
|
|
215
|
+
blob_id = excluded.blob_id, uploaded_at = excluded.uploaded_at`),
|
|
216
|
+
getPart: this.db.prepare('SELECT * FROM upload_parts WHERE upload_id = ? AND part_number = ?'),
|
|
217
|
+
listParts: this.db.prepare(
|
|
218
|
+
'SELECT * FROM upload_parts WHERE upload_id = ? AND part_number > ? ORDER BY part_number ASC LIMIT ?'),
|
|
219
|
+
allParts: this.db.prepare('SELECT * FROM upload_parts WHERE upload_id = ? ORDER BY part_number ASC'),
|
|
220
|
+
deleteParts: this.db.prepare('DELETE FROM upload_parts WHERE upload_id = ?'),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this._sequence = this.statements.maxSequence.get().value
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
close() {
|
|
227
|
+
this.db.close()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
transaction(fn) {
|
|
231
|
+
this.db.exec('BEGIN IMMEDIATE')
|
|
232
|
+
try {
|
|
233
|
+
const result = fn()
|
|
234
|
+
this.db.exec('COMMIT')
|
|
235
|
+
return result
|
|
236
|
+
} catch (err) {
|
|
237
|
+
try { this.db.exec('ROLLBACK') } catch { /* already rolled back */ }
|
|
238
|
+
throw err
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Monotonic and roughly time-ordered; used to order versions newest-first. */
|
|
243
|
+
nextSequence() {
|
|
244
|
+
this._sequence = Math.max(this._sequence + 1, Date.now())
|
|
245
|
+
return this._sequence
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ---------------------------- buckets ---------------------------- */
|
|
249
|
+
|
|
250
|
+
createBucket(name, region) {
|
|
251
|
+
this.statements.createBucket.run(name, Date.now(), region)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
getBucket(name) {
|
|
255
|
+
const row = this.statements.getBucket.get(name)
|
|
256
|
+
return row ? { name: row.name, createdAt: new Date(row.created_at), region: row.region } : null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
listBuckets() {
|
|
260
|
+
return this.statements.listBuckets.all()
|
|
261
|
+
.map((row) => ({ name: row.name, createdAt: new Date(row.created_at), region: row.region }))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
deleteBucket(name) {
|
|
265
|
+
this.statements.deleteAllConfig.run(name)
|
|
266
|
+
this.statements.deleteAllObjects.run(name)
|
|
267
|
+
this.statements.deleteBucket.run(name)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
isBucketEmpty(name) {
|
|
271
|
+
return this.statements.countObjects.get(name).total === 0
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ------------------------ bucket subresources -------------------- */
|
|
275
|
+
|
|
276
|
+
getConfig(bucket, name) {
|
|
277
|
+
const row = this.statements.getConfig.get(bucket, name)
|
|
278
|
+
return row ? JSON.parse(row.value) : null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
putConfig(bucket, name, value) {
|
|
282
|
+
this.statements.putConfig.run(bucket, name, JSON.stringify(value))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
deleteConfig(bucket, name) {
|
|
286
|
+
this.statements.deleteConfig.run(bucket, name)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Every bucket carrying a given configuration, for background sweeps. */
|
|
290
|
+
bucketsWithConfig(name) {
|
|
291
|
+
return this.statements.bucketsWithConfig.all(name)
|
|
292
|
+
.map((row) => ({ bucket: row.bucket, value: JSON.parse(row.value) }))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* ---------------------------- objects ---------------------------- */
|
|
296
|
+
|
|
297
|
+
putObject(record) {
|
|
298
|
+
this.statements.putObject.run(
|
|
299
|
+
record.bucket,
|
|
300
|
+
toKeyBuffer(record.key),
|
|
301
|
+
record.versionId ?? NULL_VERSION,
|
|
302
|
+
record.sequence ?? this.nextSequence(),
|
|
303
|
+
record.isLatest === false ? 0 : 1,
|
|
304
|
+
record.isDeleteMarker ? 1 : 0,
|
|
305
|
+
record.size,
|
|
306
|
+
record.etag,
|
|
307
|
+
record.contentType ?? null,
|
|
308
|
+
record.lastModified instanceof Date ? record.lastModified.getTime() : record.lastModified,
|
|
309
|
+
record.blobId ?? null,
|
|
310
|
+
record.parts ? JSON.stringify(record.parts) : null,
|
|
311
|
+
record.metadata && Object.keys(record.metadata).length ? JSON.stringify(record.metadata) : null,
|
|
312
|
+
record.checksums && Object.keys(record.checksums).length ? JSON.stringify(record.checksums) : null,
|
|
313
|
+
record.tags && Object.keys(record.tags).length ? JSON.stringify(record.tags) : null,
|
|
314
|
+
record.encryption ? JSON.stringify(record.encryption) : null,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
getObject(bucket, key, versionId = null) {
|
|
319
|
+
const row = versionId
|
|
320
|
+
? this.statements.getVersion.get(bucket, toKeyBuffer(key), versionId)
|
|
321
|
+
: this.statements.getLatest.get(bucket, toKeyBuffer(key))
|
|
322
|
+
return decodeRow(row)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
allVersionsOfKey(bucket, key) {
|
|
326
|
+
return this.statements.allVersionsOfKey.all(bucket, toKeyBuffer(key)).map(decodeRow)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
clearLatest(bucket, key) {
|
|
330
|
+
this.statements.clearLatest.run(bucket, toKeyBuffer(key))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** After removing the current version, the next newest takes its place. */
|
|
334
|
+
promoteLatest(bucket, key) {
|
|
335
|
+
const keyBuffer = toKeyBuffer(key)
|
|
336
|
+
this.statements.promoteLatest.run(bucket, keyBuffer, bucket, keyBuffer)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
deleteVersion(bucket, key, versionId) {
|
|
340
|
+
return this.statements.deleteVersion.run(bucket, toKeyBuffer(key), versionId).changes > 0
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
setTags(bucket, key, versionId, tags) {
|
|
344
|
+
this.statements.updateTags.run(
|
|
345
|
+
tags && Object.keys(tags).length ? JSON.stringify(tags) : null,
|
|
346
|
+
bucket, toKeyBuffer(key), versionId,
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Every blob referenced by the bucket, for garbage collection on drop. */
|
|
351
|
+
blobsInBucket(bucket) {
|
|
352
|
+
const ids = []
|
|
353
|
+
for (const row of this.statements.allObjects.all(bucket)) {
|
|
354
|
+
if (row.blob_id) ids.push(row.blob_id)
|
|
355
|
+
if (row.parts) for (const part of JSON.parse(row.parts)) ids.push(part.blobId)
|
|
356
|
+
}
|
|
357
|
+
return ids
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
objectsModifiedBefore(bucket, cutoff) {
|
|
361
|
+
return this.statements.expiredObjects.all(bucket, cutoff).map(decodeRow)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
_scan(bucket, cursor, upperBound, limit) {
|
|
365
|
+
return upperBound
|
|
366
|
+
? this.statements.scanLatestRange.all(bucket, cursor, upperBound, limit)
|
|
367
|
+
: this.statements.scanLatest.all(bucket, cursor, limit)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Ordered prefix scan with delimiter roll-up. When a key falls inside a
|
|
372
|
+
* common prefix the cursor jumps past the whole group rather than walking
|
|
373
|
+
* every key in it.
|
|
374
|
+
*/
|
|
375
|
+
listObjects(bucket, {
|
|
376
|
+
prefix = '', delimiter = '', maxKeys = 1000, startAfter = '', cursor: resumeCursor = null,
|
|
377
|
+
} = {}) {
|
|
378
|
+
const prefixBuf = toKeyBuffer(prefix)
|
|
379
|
+
const delimiterBuf = delimiter ? toKeyBuffer(delimiter) : null
|
|
380
|
+
const upperBound = prefixUpperBound(prefixBuf)
|
|
381
|
+
|
|
382
|
+
let cursor = prefixBuf
|
|
383
|
+
// A resume cursor is an inclusive lower bound; startAfter is exclusive.
|
|
384
|
+
if (resumeCursor && Buffer.compare(resumeCursor, cursor) > 0) cursor = resumeCursor
|
|
385
|
+
if (startAfter) {
|
|
386
|
+
const startAfterBuf = keySuccessor(startAfter)
|
|
387
|
+
if (Buffer.compare(startAfterBuf, cursor) > 0) cursor = startAfterBuf
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const contents = []
|
|
391
|
+
const commonPrefixes = []
|
|
392
|
+
const seenPrefixes = new Set()
|
|
393
|
+
let truncated = false
|
|
394
|
+
let nextCursor = null
|
|
395
|
+
|
|
396
|
+
outer: while (contents.length + commonPrefixes.length < maxKeys) {
|
|
397
|
+
const rows = this._scan(bucket, cursor, upperBound, LIST_BATCH)
|
|
398
|
+
if (rows.length === 0) break
|
|
399
|
+
|
|
400
|
+
for (const row of rows) {
|
|
401
|
+
const key = Buffer.from(row.key)
|
|
402
|
+
|
|
403
|
+
if (delimiterBuf) {
|
|
404
|
+
const tail = key.subarray(prefixBuf.length)
|
|
405
|
+
const at = tail.indexOf(delimiterBuf)
|
|
406
|
+
if (at !== -1) {
|
|
407
|
+
const groupPrefix = key.subarray(0, prefixBuf.length + at + delimiterBuf.length)
|
|
408
|
+
const groupKey = groupPrefix.toString('utf8')
|
|
409
|
+
if (!seenPrefixes.has(groupKey)) {
|
|
410
|
+
if (contents.length + commonPrefixes.length >= maxKeys) {
|
|
411
|
+
truncated = true
|
|
412
|
+
nextCursor = cursor
|
|
413
|
+
break outer
|
|
414
|
+
}
|
|
415
|
+
seenPrefixes.add(groupKey)
|
|
416
|
+
commonPrefixes.push(groupKey)
|
|
417
|
+
}
|
|
418
|
+
const bound = prefixUpperBound(groupPrefix)
|
|
419
|
+
if (!bound) { cursor = null; break outer }
|
|
420
|
+
cursor = bound
|
|
421
|
+
continue outer
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (contents.length + commonPrefixes.length >= maxKeys) {
|
|
426
|
+
truncated = true
|
|
427
|
+
nextCursor = cursor
|
|
428
|
+
break outer
|
|
429
|
+
}
|
|
430
|
+
contents.push(decodeRow(row))
|
|
431
|
+
cursor = keySuccessor(key)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (rows.length < LIST_BATCH) break
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (!truncated && cursor && contents.length + commonPrefixes.length >= maxKeys) {
|
|
438
|
+
truncated = this._scan(bucket, cursor, upperBound, 1).length > 0
|
|
439
|
+
if (truncated) nextCursor = cursor
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
contents,
|
|
444
|
+
commonPrefixes,
|
|
445
|
+
truncated,
|
|
446
|
+
nextCursor: truncated ? nextCursor ?? cursor : null,
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* ListObjectVersions: every version and delete marker, ordered by key then
|
|
452
|
+
* newest-first, paged on the (key-marker, version-id-marker) pair S3 uses.
|
|
453
|
+
*/
|
|
454
|
+
listVersions(bucket, {
|
|
455
|
+
prefix = '', delimiter = '', maxKeys = 1000, keyMarker = '', versionIdMarker = '',
|
|
456
|
+
} = {}) {
|
|
457
|
+
const prefixBuf = toKeyBuffer(prefix)
|
|
458
|
+
const delimiterBuf = delimiter ? toKeyBuffer(delimiter) : null
|
|
459
|
+
const upperBound = prefixUpperBound(prefixBuf)
|
|
460
|
+
|
|
461
|
+
let cursorKey = prefixBuf
|
|
462
|
+
let cursorSequence = Number.MAX_SAFE_INTEGER
|
|
463
|
+
if (keyMarker) {
|
|
464
|
+
const markerKey = toKeyBuffer(keyMarker)
|
|
465
|
+
if (Buffer.compare(markerKey, cursorKey) >= 0) {
|
|
466
|
+
cursorKey = markerKey
|
|
467
|
+
// Without a version marker the whole key has been consumed already.
|
|
468
|
+
cursorSequence = Number.MIN_SAFE_INTEGER
|
|
469
|
+
if (versionIdMarker) {
|
|
470
|
+
const row = this.statements.getVersion.get(bucket, markerKey, versionIdMarker)
|
|
471
|
+
if (row) cursorSequence = row.sequence
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const scan = (limit) => (upperBound
|
|
477
|
+
? this.statements.scanVersionsRange.all(bucket, cursorKey, cursorKey, cursorSequence, upperBound, limit)
|
|
478
|
+
: this.statements.scanVersions.all(bucket, cursorKey, cursorKey, cursorSequence, limit))
|
|
479
|
+
|
|
480
|
+
const versions = []
|
|
481
|
+
const commonPrefixes = []
|
|
482
|
+
const seenPrefixes = new Set()
|
|
483
|
+
let truncated = false
|
|
484
|
+
let nextKeyMarker = null
|
|
485
|
+
let nextVersionIdMarker = null
|
|
486
|
+
|
|
487
|
+
outer: while (versions.length + commonPrefixes.length < maxKeys) {
|
|
488
|
+
const rows = scan(LIST_BATCH)
|
|
489
|
+
if (rows.length === 0) break
|
|
490
|
+
|
|
491
|
+
for (const row of rows) {
|
|
492
|
+
const key = Buffer.from(row.key)
|
|
493
|
+
|
|
494
|
+
if (delimiterBuf) {
|
|
495
|
+
const tail = key.subarray(prefixBuf.length)
|
|
496
|
+
const at = tail.indexOf(delimiterBuf)
|
|
497
|
+
if (at !== -1) {
|
|
498
|
+
const groupPrefix = key.subarray(0, prefixBuf.length + at + delimiterBuf.length)
|
|
499
|
+
const groupKey = groupPrefix.toString('utf8')
|
|
500
|
+
if (!seenPrefixes.has(groupKey)) {
|
|
501
|
+
if (versions.length + commonPrefixes.length >= maxKeys) {
|
|
502
|
+
truncated = true
|
|
503
|
+
break outer
|
|
504
|
+
}
|
|
505
|
+
seenPrefixes.add(groupKey)
|
|
506
|
+
commonPrefixes.push(groupKey)
|
|
507
|
+
}
|
|
508
|
+
const bound = prefixUpperBound(groupPrefix)
|
|
509
|
+
if (!bound) break outer
|
|
510
|
+
cursorKey = bound
|
|
511
|
+
cursorSequence = Number.MAX_SAFE_INTEGER
|
|
512
|
+
continue outer
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (versions.length + commonPrefixes.length >= maxKeys) {
|
|
517
|
+
truncated = true
|
|
518
|
+
break outer
|
|
519
|
+
}
|
|
520
|
+
const record = decodeRow(row)
|
|
521
|
+
versions.push(record)
|
|
522
|
+
cursorKey = key
|
|
523
|
+
cursorSequence = row.sequence
|
|
524
|
+
nextKeyMarker = key.toString('utf8')
|
|
525
|
+
nextVersionIdMarker = record.versionId
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (rows.length < LIST_BATCH) break
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (!truncated && versions.length + commonPrefixes.length >= maxKeys) {
|
|
532
|
+
truncated = scan(1).length > 0
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
versions,
|
|
537
|
+
commonPrefixes,
|
|
538
|
+
truncated,
|
|
539
|
+
nextKeyMarker: truncated ? nextKeyMarker : null,
|
|
540
|
+
nextVersionIdMarker: truncated ? nextVersionIdMarker : null,
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/* -------------------------- multipart ---------------------------- */
|
|
545
|
+
|
|
546
|
+
createUpload({ uploadId, bucket, key, contentType, metadata, tags, encryption }) {
|
|
547
|
+
this.statements.createUpload.run(
|
|
548
|
+
uploadId, bucket, toKeyBuffer(key), Date.now(),
|
|
549
|
+
contentType ?? null,
|
|
550
|
+
metadata && Object.keys(metadata).length ? JSON.stringify(metadata) : null,
|
|
551
|
+
tags && Object.keys(tags).length ? JSON.stringify(tags) : null,
|
|
552
|
+
encryption ? JSON.stringify(encryption) : null,
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
_decodeUpload(row) {
|
|
557
|
+
if (!row) return null
|
|
558
|
+
return {
|
|
559
|
+
uploadId: row.upload_id,
|
|
560
|
+
bucket: row.bucket,
|
|
561
|
+
key: Buffer.from(row.key),
|
|
562
|
+
initiatedAt: new Date(row.initiated_at),
|
|
563
|
+
contentType: row.content_type ?? undefined,
|
|
564
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : {},
|
|
565
|
+
tags: row.tags ? JSON.parse(row.tags) : {},
|
|
566
|
+
encryption: row.encryption ? JSON.parse(row.encryption) : null,
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
getUpload(uploadId) {
|
|
571
|
+
return this._decodeUpload(this.statements.getUpload.get(uploadId))
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
listUploads(bucket, maxUploads = 1000) {
|
|
575
|
+
return this.statements.listUploads.all(bucket, maxUploads).map((row) => this._decodeUpload(row))
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
uploadsStartedBefore(bucket, cutoff) {
|
|
579
|
+
return this.statements.staleUploads.all(bucket, cutoff).map((row) => this._decodeUpload(row))
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
putPart({ uploadId, partNumber, size, etag, blobId }) {
|
|
583
|
+
this.statements.putPart.run(uploadId, partNumber, size, etag, blobId, Date.now())
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
getPart(uploadId, partNumber) {
|
|
587
|
+
const row = this.statements.getPart.get(uploadId, partNumber)
|
|
588
|
+
return row
|
|
589
|
+
? { partNumber: row.part_number, size: row.size, etag: row.etag, blobId: row.blob_id, uploadedAt: new Date(row.uploaded_at) }
|
|
590
|
+
: null
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
listParts(uploadId, { partNumberMarker = 0, maxParts = 1000 } = {}) {
|
|
594
|
+
return this.statements.listParts.all(uploadId, partNumberMarker, maxParts).map((row) => ({
|
|
595
|
+
partNumber: row.part_number,
|
|
596
|
+
size: row.size,
|
|
597
|
+
etag: row.etag,
|
|
598
|
+
blobId: row.blob_id,
|
|
599
|
+
uploadedAt: new Date(row.uploaded_at),
|
|
600
|
+
}))
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
allParts(uploadId) {
|
|
604
|
+
return this.statements.allParts.all(uploadId).map((row) => ({
|
|
605
|
+
partNumber: row.part_number,
|
|
606
|
+
size: row.size,
|
|
607
|
+
etag: row.etag,
|
|
608
|
+
blobId: row.blob_id,
|
|
609
|
+
}))
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
deleteUpload(uploadId) {
|
|
613
|
+
this.statements.deleteParts.run(uploadId)
|
|
614
|
+
this.statements.deleteUpload.run(uploadId)
|
|
615
|
+
}
|
|
616
|
+
}
|