@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.
@@ -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
+ }