@e280/mammoth 0.1.0-1 → 0.1.0-10

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.
Files changed (74) hide show
  1. package/README.md +114 -5
  2. package/package.json +7 -4
  3. package/s/browser/example.ts +10 -11
  4. package/s/browser/index.ts +1 -1
  5. package/s/browser/{iceberg-opfs.ts → opfs-bucket.ts} +2 -8
  6. package/s/core/{hash-stream.ts → analyze.ts} +6 -2
  7. package/s/core/consts.ts +8 -0
  8. package/s/core/index.ts +3 -2
  9. package/s/core/mammoth.ts +55 -45
  10. package/s/core/{iceberg-memory.ts → memory-bucket.ts} +2 -6
  11. package/s/core/streamify.ts +18 -0
  12. package/s/core/types.ts +37 -9
  13. package/s/core/utils/is-expired.ts +10 -0
  14. package/s/core/utils/manifest.ts +113 -0
  15. package/s/core/utils/not-found.ts +7 -0
  16. package/s/core/utils/save.ts +30 -0
  17. package/s/node/disk-bucket.ts +55 -0
  18. package/s/node/index.ts +4 -0
  19. package/s/node/utils/is-not-found.ts +9 -0
  20. package/s/test.ts +37 -12
  21. package/x/browser/example.js +10 -11
  22. package/x/browser/example.js.map +1 -1
  23. package/x/browser/index.d.ts +1 -1
  24. package/x/browser/index.js +1 -1
  25. package/x/browser/index.js.map +1 -1
  26. package/x/browser/{iceberg-opfs.d.ts → opfs-bucket.d.ts} +2 -3
  27. package/x/browser/{iceberg-opfs.js → opfs-bucket.js} +2 -7
  28. package/x/browser/opfs-bucket.js.map +1 -0
  29. package/x/core/analyze.d.ts +2 -0
  30. package/x/core/{hash-stream.js → analyze.js} +6 -3
  31. package/x/core/analyze.js.map +1 -0
  32. package/x/core/consts.d.ts +4 -0
  33. package/x/core/consts.js +6 -0
  34. package/x/core/consts.js.map +1 -0
  35. package/x/core/index.d.ts +3 -2
  36. package/x/core/index.js +3 -2
  37. package/x/core/index.js.map +1 -1
  38. package/x/core/mammoth.d.ts +7 -5
  39. package/x/core/mammoth.js +52 -40
  40. package/x/core/mammoth.js.map +1 -1
  41. package/x/core/{iceberg-memory.d.ts → memory-bucket.d.ts} +2 -3
  42. package/x/core/{iceberg-memory.js → memory-bucket.js} +2 -5
  43. package/x/core/memory-bucket.js.map +1 -0
  44. package/x/core/streamify.d.ts +1 -0
  45. package/x/core/streamify.js +15 -0
  46. package/x/core/streamify.js.map +1 -0
  47. package/x/core/types.d.ts +27 -9
  48. package/x/core/utils/is-expired.d.ts +2 -0
  49. package/x/core/utils/is-expired.js +7 -0
  50. package/x/core/utils/is-expired.js.map +1 -0
  51. package/x/core/utils/manifest.d.ts +19 -0
  52. package/x/core/utils/manifest.js +91 -0
  53. package/x/core/utils/manifest.js.map +1 -0
  54. package/x/core/utils/not-found.d.ts +2 -0
  55. package/x/core/utils/not-found.js +4 -0
  56. package/x/core/utils/not-found.js.map +1 -0
  57. package/x/core/utils/save.d.ts +2 -0
  58. package/x/core/utils/save.js +19 -0
  59. package/x/core/utils/save.js.map +1 -0
  60. package/x/node/disk-bucket.d.ts +9 -0
  61. package/x/node/disk-bucket.js +43 -0
  62. package/x/node/disk-bucket.js.map +1 -0
  63. package/x/node/index.d.ts +2 -0
  64. package/x/node/index.js +3 -0
  65. package/x/node/index.js.map +1 -0
  66. package/x/node/utils/is-not-found.d.ts +1 -0
  67. package/x/node/utils/is-not-found.js +6 -0
  68. package/x/node/utils/is-not-found.js.map +1 -0
  69. package/x/test.js +35 -12
  70. package/x/test.js.map +1 -1
  71. package/x/browser/iceberg-opfs.js.map +0 -1
  72. package/x/core/hash-stream.d.ts +0 -1
  73. package/x/core/hash-stream.js.map +0 -1
  74. package/x/core/iceberg-memory.js.map +0 -1
package/README.md CHANGED
@@ -1,13 +1,122 @@
1
1
 
2
- # 🦣 @e280/mammoth
2
+ ![](https://i.imgur.com/1TQmFwW.png)
3
3
 
4
- big files. small api.
4
+ # 🦣 mammoth
5
+ > *big files. small api.*
6
+
7
+ **mammoth is a file storage typescript library.** we use it in our web apps to store files like user uploads. it's content-addressed. files are streamed into a bucket, identified by their blake3 hash, and deduplicated. mammoth works the same whether it's backed by a bucket in memory, on disk, in the cloud, or in the browser's opfs. mammoth uses [kv](https://github.com/e280/kv) for bookkeeping.
8
+
9
+ ```bash
10
+ npm install @e280/mammoth @e280/kv
11
+ ```
12
+
13
+ ```ts
14
+ import {Mammoth} from "@e280/mammoth"
15
+
16
+ const mammoth = new Mammoth()
17
+ ```
18
+
19
+ ## 🦣 mammoth stores files.
20
+ - **write a file,** and you get back its blake3 `hash` and `size` in bytes.
21
+ ```ts
22
+ const hash = await mammoth.write(blob.stream())
23
+ ```
24
+ - **read a file,** identified by its hash.
25
+ ```ts
26
+ const blob = await mammoth.read(hash)
27
+ ```
28
+ - **delete a file.**
29
+ ```ts
30
+ await mammoth.delete(hash)
31
+ ```
32
+
33
+ ## 🦣 mammoth is bucket-agnostic.
34
+ - **memory bucket,** for testing.
35
+ ```ts
36
+ import {Mammoth, MemoryBucket} from "@e280/mammoth"
37
+ import {Kv} from "@e280/kv"
38
+
39
+ const mammoth = new Mammoth(
40
+
41
+ // bucket for blob storage
42
+ new MemoryBucket(),
43
+
44
+ // kv for metadata bookkeeping
45
+ new Kv(),
46
+ )
47
+ ```
48
+ defaults shown, this is equivalent:
49
+ ```ts
50
+ const mammoth = new Mammoth()
51
+ ```
52
+ - **disk bucket,** for nodejs servers. *(note the import paths)*
53
+ ```ts
54
+ import {Mammoth} from "@e280/mammoth"
55
+ import {DiskBucket} from "@e280/mammoth/node"
56
+ import {Kv} from "@e280/kv"
57
+ import {LevelDriver} from "@e280/kv/level"
58
+
59
+ const mammoth = new Mammoth(
60
+ new DiskBucket("./data/bucket"),
61
+ new Kv(new LevelDriver("./data/kv")),
62
+ )
63
+ ```
64
+ - **opfs bucket,** for local storage in the browser. *(note the import paths)*
65
+ ```ts
66
+ import {Mammoth} from "@e280/mammoth"
67
+ import {OpfsBucket} from "@e280/mammoth/browser"
68
+ import {Kv, StorageDriver} from "@e280/kv"
69
+
70
+ const mammoth = new Mammoth(
71
+ new OpfsBucket(await navigator.storage.getDirectory()),
72
+ new Kv(new StorageDriver(localStorage)),
73
+ )
74
+ ```
75
+
76
+ ## 🦣 more mammoth methods.
77
+ - **check if a file exists,** get back a boolean.
78
+ ```ts
79
+ const exists = await mammoth.has(hash)
80
+ ```
81
+ - **get file info,** including `size` in bytes, `added` timestamp, and bucket `id`.
82
+ ```ts
83
+ const {size, added, id} = await mammoth.info(hash)
84
+ ```
85
+ - **get stats,** for the whole datalake.
86
+ ```ts
87
+ await mammoth.stats()
88
+ // {count: 123, size: 123456789}
89
+ ```
90
+ - **loop over all hashes,** for all stored files.
91
+ ```ts
92
+ for await (const hash of mammoth.hashes())
93
+ console.log(hash)
94
+ ```
95
+
96
+ ## 🦣 mammoth-brained tips.
97
+ - **`analyze` is for hashing files.**
98
+ ```ts
99
+ import {analyze} from "@e280/mammoth"
100
+
101
+ const {hash, size} = await analyze(blob.stream())
102
+ ```
103
+ - **`analyze` can help you avoid unnecessary uploads.**
104
+ ```ts
105
+ const {hash} = await analyze(blob.stream())
106
+
107
+ if (!await mammoth.has(hash))
108
+ await mammoth.write(blob.stream())
109
+ ```
110
+ - **`streamify` makes a stream for a Uint8Array.**
111
+ ```ts
112
+ import {streamify} from "@e280/mammoth"
113
+
114
+ const readable = await streamify(new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]))
115
+ ```
5
116
 
6
117
 
7
118
 
8
119
  <br/><br/>
9
120
 
10
- ## 💖 mammoth is by e280
11
- reward us with github stars
12
- build with us at https://e280.org/ but only if you're cool
121
+ *https://e280.org/*
13
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e280/mammoth",
3
- "version": "0.1.0-1",
3
+ "version": "0.1.0-10",
4
4
  "description": "big file storage",
5
5
  "license": "MIT",
6
6
  "author": "Chase Moskal <chasemoskal@gmail.com>",
@@ -9,7 +9,8 @@
9
9
  "sideEffects": false,
10
10
  "exports": {
11
11
  ".": "./x/core/index.js",
12
- "./browser": "./x/browser/index.js"
12
+ "./browser": "./x/browser/index.js",
13
+ "./node": "./x/node/index.js"
13
14
  },
14
15
  "files": [
15
16
  "x",
@@ -21,9 +22,11 @@
21
22
  "test": "node x/test.js",
22
23
  "count": "find s -path '*/_archive' -prune -o -name '*.ts' -exec wc -l {} +"
23
24
  },
25
+ "peerDependencies": {
26
+ "@e280/kv": "^0.1.5"
27
+ },
24
28
  "dependencies": {
25
- "@e280/kv": "^0.1.5",
26
- "@e280/stz": "^0.3.4",
29
+ "@e280/stz": "^0.3.5",
27
30
  "@noble/hashes": "^2.2.0"
28
31
  },
29
32
  "devDependencies": {
@@ -1,26 +1,25 @@
1
1
 
2
2
  import {Kv, StorageDriver} from "@e280/kv"
3
3
  import {Mammoth} from "../core/mammoth.js"
4
- import {IcebergOpfs} from "./iceberg-opfs.js"
4
+ import {OpfsBucket} from "./opfs-bucket.js"
5
5
 
6
6
  export async function example() {
7
7
 
8
- // setup the localstorage kv index
8
+ // setup the localstorage kv
9
9
  const driver = new StorageDriver(window.localStorage)
10
- const kv = new Kv(driver)
11
- const index = kv.scope("glacier_index")
10
+ const kv = new Kv(driver).scope("mammoth")
12
11
 
13
- // setup the opfs iceberg
12
+ // setup the opfs file bucket
14
13
  const root = await navigator.storage.getDirectory()
15
- const directory = await root.getDirectoryHandle("glacier", {create: true})
16
- const iceberg = new IcebergOpfs(directory)
14
+ const directory = await root.getDirectoryHandle("mammoth", {create: true})
15
+ const bucket = new OpfsBucket(directory)
17
16
 
18
17
  // setup the mammoth
19
- const mammoth = new Mammoth(index, iceberg)
20
- const bigFile = new Blob([new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF])])
18
+ const mammoth = new Mammoth(bucket, kv)
19
+ const myFile = new Blob([new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF])])
21
20
 
22
21
  // write a file as an example
23
- const hash = await mammoth.write(bigFile.stream())
24
- console.log(`mammoth stored file by hash "${hash}"`)
22
+ const hash = await mammoth.write(myFile.stream())
23
+ console.log(`mammoth stored file "${hash}"`)
25
24
  }
26
25
 
@@ -1,4 +1,4 @@
1
1
 
2
2
  export * from "../core/index.js"
3
- export * from "./iceberg-opfs.js"
3
+ export * from "./opfs-bucket.js"
4
4
 
@@ -1,8 +1,8 @@
1
1
 
2
- import {Iceberg, Id} from "../core/types.js"
2
+ import {Bucket, Id} from "../core/types.js"
3
3
  import {isNotFound} from "./utils/is-not-found.js"
4
4
 
5
- export class IcebergOpfs implements Iceberg {
5
+ export class OpfsBucket implements Bucket {
6
6
  #directory
7
7
 
8
8
  constructor(directory: FileSystemDirectoryHandle) {
@@ -20,12 +20,6 @@ export class IcebergOpfs implements Iceberg {
20
20
  }
21
21
  }
22
22
 
23
- async size(id: Id) {
24
- const handle = await this.#directory.getFileHandle(id)
25
- const file = await handle.getFile()
26
- return file.size
27
- }
28
-
29
23
  async delete(id: Id) {
30
24
  try {
31
25
  await this.#directory.removeEntry(id)
@@ -1,19 +1,23 @@
1
1
 
2
2
  import {hex, nap} from "@e280/stz"
3
3
  import {blake3} from "@noble/hashes/blake3.js"
4
+ import {Analysis} from "./types.js"
4
5
  import {relaxer} from "./utils/relaxer.js"
5
6
 
6
- export async function hashStream(readable: ReadableStream<Uint8Array>) {
7
+ export async function analyze(readable: ReadableStream<Uint8Array>): Promise<Analysis> {
8
+ let size = 0
7
9
  const hasher = blake3.create()
8
10
  const relax = relaxer()
9
11
 
10
12
  for await (const chunk of readable) {
11
13
  hasher.update(chunk)
14
+ size += chunk.byteLength
12
15
 
13
16
  if (relax())
14
17
  await nap()
15
18
  }
16
19
 
17
- return hex.fromBytes(hasher.digest())
20
+ const hash = hex.fromBytes(hasher.digest())
21
+ return {hash, size}
18
22
  }
19
23
 
@@ -0,0 +1,8 @@
1
+
2
+ import {time} from "@e280/stz"
3
+
4
+ export const consts = {
5
+ max_jobs: 64,
6
+ expiry_time: time.days(7),
7
+ }
8
+
package/s/core/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
 
2
- export * from "./hash-stream.js"
3
- export * from "./iceberg-memory.js"
2
+ export * from "./analyze.js"
4
3
  export * from "./mammoth.js"
4
+ export * from "./memory-bucket.js"
5
+ export * from "./streamify.js"
5
6
  export * from "./types.js"
6
7
 
package/s/core/mammoth.ts CHANGED
@@ -1,72 +1,82 @@
1
1
 
2
2
  import {Kv} from "@e280/kv"
3
- import {got, hex} from "@e280/stz"
4
- import {blake3} from "@noble/hashes/blake3.js"
3
+ import {lane, queue} from "@e280/stz"
5
4
 
5
+ import {consts} from "./consts.js"
6
+ import {save} from "./utils/save.js"
7
+ import {Hash, Bucket} from "./types.js"
8
+ import {Manifest} from "./utils/manifest.js"
6
9
  import {randomId} from "./utils/random-id.js"
7
- import {Glacier, Hash, Iceberg, Id} from "./types.js"
10
+ import {MemoryBucket} from "./memory-bucket.js"
8
11
 
9
- export class Mammoth implements Glacier {
10
- #index
11
- #iceberg
12
+ /** file storage datalake, content-addressed with blake3 hashes. */
13
+ export class Mammoth {
14
+ #bucket
15
+ #manifest
16
+ #wholesome = lane(consts.max_jobs)
12
17
 
13
- constructor(index: Kv<string>, iceberg: Iceberg) {
14
- this.#index = index
15
- this.#iceberg = iceberg
18
+ constructor(bucket: Bucket = new MemoryBucket(), kv = new Kv()) {
19
+ this.#bucket = bucket
20
+ this.#manifest = new Manifest(kv)
21
+ }
22
+
23
+ async* hashes() {
24
+ yield* this.#manifest.hashes()
16
25
  }
17
26
 
18
27
  async has(hash: Hash) {
19
- return this.#index.has(hash)
28
+ return this.#manifest.hasHash(hash)
29
+ }
30
+
31
+ async info(hash: Hash) {
32
+ return this.#manifest.needInfo(hash)
20
33
  }
21
34
 
22
- async size(hash: Hash) {
23
- const id = await this.#needFileId(hash)
24
- return this.#iceberg.size(id)
35
+ async stats() {
36
+ return this.#manifest.getStats()
25
37
  }
26
38
 
27
39
  async read(hash: Hash) {
28
- const id = await this.#needFileId(hash)
29
- return this.#iceberg.read(id)
40
+ const {id} = await this.#manifest.needInfo(hash)
41
+ return this.#bucket.read(id)
30
42
  }
31
43
 
32
44
  async delete(hash: Hash) {
33
- const id = await this.#getFileId(hash)
34
- if (id) {
35
- await this.#index.del(hash)
36
- await this.#iceberg.delete(id)
37
- }
45
+ await this.#wholesome(async() => {
46
+ const info = await this.#manifest.getInfo(hash)
47
+ if (info)
48
+ await this.#manifest.scheduleDeletion(hash, info)
49
+ })
50
+ await this.#cleanup()
38
51
  }
39
52
 
40
53
  async write(readable: ReadableStream<Uint8Array>): Promise<Hash> {
41
54
  const id = randomId()
42
- const pipe = new TransformStream()
43
- const done = this.#iceberg.write(id, pipe.readable)
44
- const writer = pipe.writable.getWriter()
45
- const hasher = blake3.create()
46
-
47
- for await (const chunk of readable) {
48
- await writer.write(chunk as Uint8Array<ArrayBuffer>)
49
- hasher.update(chunk)
55
+ await this.#manifest.addWip(id)
56
+ try {
57
+ const analysis = await save(this.#bucket, id, readable)
58
+ const {hash, size} = analysis
59
+ const info = {id, size, added: Date.now()}
60
+ await this.#wholesome(() => this.#manifest.commit(hash, info))
61
+ return analysis.hash
62
+ }
63
+ catch (error) {
64
+ await this.#manifest.moveWipToTrash(id)
65
+ throw error
66
+ }
67
+ finally {
68
+ void this.#cleanup().catch(e => console.error("cleanup failed", e))
50
69
  }
51
-
52
- await writer.close()
53
- await done
54
- const hash = hex.fromBytes(hasher.digest())
55
- await this.delete(hash)
56
- await this.#index.set(hash, id)
57
- return hash
58
70
  }
59
71
 
60
- async #getFileId(hash: Hash): Promise<Id | undefined> {
61
- return this.#index.get<string>(hash)
62
- }
72
+ #cleanup = queue(async() => {
73
+ for await (const id of this.#manifest.getExpiredWipIds())
74
+ await this.#manifest.moveWipToTrash(id)
63
75
 
64
- async #needFileId(hash: Hash): Promise<Id> {
65
- return got(await this.#getFileId(hash), `file not found by hash "${hash}"`)
66
- }
67
-
68
- async* keys() {
69
- yield* this.#index.keys()
70
- }
76
+ for await (const id of this.#manifest.listTrashIds()) {
77
+ await this.#bucket.delete(id)
78
+ await this.#manifest.dropTrashRecord(id)
79
+ }
80
+ }, consts.max_jobs)
71
81
  }
72
82
 
@@ -1,19 +1,15 @@
1
1
 
2
2
  import {bytes, got, nap} from "@e280/stz"
3
- import {Iceberg, Id} from "./types.js"
3
+ import {Bucket, Id} from "./types.js"
4
4
  import {relaxer} from "./utils/relaxer.js"
5
5
 
6
- export class IcebergMemory implements Iceberg {
6
+ export class MemoryBucket implements Bucket {
7
7
  #map = new Map<string, Uint8Array>()
8
8
 
9
9
  async has(id: Id) {
10
10
  return this.#map.has(id)
11
11
  }
12
12
 
13
- async size(id: Id) {
14
- return got(this.#map.get(id)).byteLength
15
- }
16
-
17
13
  async delete(id: Id) {
18
14
  this.#map.delete(id)
19
15
  }
@@ -0,0 +1,18 @@
1
+
2
+ export function streamify(bytes: Uint8Array, chunkSize = 64 * 1024) {
3
+ let offset = 0
4
+
5
+ return new ReadableStream<Uint8Array>({
6
+ pull(controller) {
7
+ if (offset >= bytes.length) {
8
+ controller.close()
9
+ return
10
+ }
11
+
12
+ const end = Math.min(offset + chunkSize, bytes.length)
13
+ controller.enqueue(bytes.subarray(offset, end))
14
+ offset = end
15
+ },
16
+ })
17
+ }
18
+
package/s/core/types.ts CHANGED
@@ -2,20 +2,48 @@
2
2
  export type Hash = string
3
3
  export type Id = string
4
4
 
5
- export type Iceberg = {
5
+ /** file blob store. */
6
+ export type Bucket = {
6
7
  has(id: Id): Promise<boolean>
7
- size(id: Id): Promise<number>
8
8
  delete(id: Id): Promise<void>
9
9
  read(id: Id): Promise<Blob>
10
10
  write(id: Id, readable: ReadableStream<Uint8Array>): Promise<void>
11
11
  }
12
12
 
13
- export type Glacier = {
14
- has(hash: Hash): Promise<boolean>
15
- size(hash: Hash): Promise<number>
16
- read(hash: Hash): Promise<Blob>
17
- delete(hash: Hash): Promise<void>
18
- write(readable: ReadableStream<Uint8Array>): Promise<Hash>
19
- keys(): AsyncIterable<Hash>
13
+ /** report about a file */
14
+ export type Analysis = {
15
+
16
+ /** blake3 hash of the file's contents */
17
+ hash: Hash
18
+
19
+ /** filesize in bytes */
20
+ size: number
21
+ }
22
+
23
+ /** metadata for a single file. */
24
+ export type Info = {
25
+
26
+ /** bucket id for this file's data. */
27
+ id: Id
28
+
29
+ /** filesize in bytes. */
30
+ size: number
31
+
32
+ /** when this file was added to the datalake. */
33
+ added: number
34
+ }
35
+
36
+ /** statistics for the whole datalake. */
37
+ export type Stats = {
38
+
39
+ /** total number of bytes in the whole datalake. */
40
+ size: number
41
+
42
+ /** total number of files in the datalake. */
43
+ count: number
44
+ }
45
+
46
+ export type Wip = {
47
+ created: number
20
48
  }
21
49
 
@@ -0,0 +1,10 @@
1
+
2
+ import {time} from "@e280/stz"
3
+ import {Wip} from "../types.js"
4
+ import {consts} from "../consts.js"
5
+
6
+ export function isExpired({created}: Wip) {
7
+ const since = Date.now() - created
8
+ return since > time.days(consts.expiry_time)
9
+ }
10
+
@@ -0,0 +1,113 @@
1
+
2
+ import {Kv} from "@e280/kv"
3
+ import {got} from "@e280/stz"
4
+ import {notFound} from "./not-found.js"
5
+ import {isExpired} from "./is-expired.js"
6
+ import {Hash, Id, Info, Stats, Wip} from "../types.js"
7
+
8
+ export class Manifest {
9
+ #kv
10
+
11
+ /** statistics about the whole datalake */
12
+ #stats
13
+
14
+ /** associates file hashes with bucket ids */
15
+ #info
16
+
17
+ /** temporary record of writes in-progress */
18
+ #wip
19
+
20
+ /** statistics about the whole datalake */
21
+ #trash
22
+
23
+ constructor(kv = new Kv()) {
24
+ this.#kv = kv
25
+ this.#stats = kv.store<Stats>("stats")
26
+ this.#info = kv.scope<Info>("info")
27
+ this.#wip = kv.scope<Wip>("wip")
28
+ this.#trash = kv.scope<true>("trash")
29
+ }
30
+
31
+ async getStats() {
32
+ return structuredClone((await this.#stats.get()) ?? {count: 0, size: 0})
33
+ }
34
+
35
+ async* hashes() {
36
+ yield* this.#info.keys()
37
+ }
38
+
39
+ async hasHash(hash: Hash) {
40
+ return this.#info.has(hash)
41
+ }
42
+
43
+ async getInfo(hash: Hash) {
44
+ return await this.#info.get(hash)
45
+ }
46
+
47
+ async needInfo(hash: Hash) {
48
+ return got(await this.getInfo(hash), notFound(hash))
49
+ }
50
+
51
+ async addWip(id: Id) {
52
+ await this.#wip.set(id, {created: Date.now()})
53
+ }
54
+
55
+ async deleteWip(...ids: Id[]) {
56
+ await this.#wip.del(...ids)
57
+ }
58
+
59
+ async* getExpiredWipIds() {
60
+ for await (const [id, wip] of this.#wip.entries()) {
61
+ if (isExpired(wip))
62
+ yield id
63
+ }
64
+ }
65
+
66
+ async moveWipToTrash(id: Id) {
67
+ await this.#kv.transaction(() => [
68
+ this.#wip.write.del(id),
69
+ this.#trash.write.set(id, true),
70
+ ])
71
+ }
72
+
73
+ async dropTrashRecord(id: Id) {
74
+ await this.#trash.del(id)
75
+ }
76
+
77
+ async* listTrashIds() {
78
+ yield* this.#trash.keys()
79
+ }
80
+
81
+ async scheduleDeletion(hash: Hash, info: Info) {
82
+ const stats = await this.getStats()
83
+ stats.count -= 1
84
+ stats.size -= info.size
85
+ await this.#kv.transaction(() => [
86
+ this.#info.write.del(hash),
87
+ this.#trash.write.set(info.id, true),
88
+ this.#stats.write.set(stats),
89
+ ])
90
+ }
91
+
92
+ async commit(hash: Hash, info: Info) {
93
+ const isNewFile = !await this.hasHash(hash)
94
+
95
+ if (isNewFile) {
96
+ const stats = await this.getStats()
97
+ stats.count += 1
98
+ stats.size += info.size
99
+ await this.#kv.transaction(() => [
100
+ this.#wip.write.del(info.id),
101
+ this.#info.write.set(hash, info),
102
+ this.#stats.write.set(stats)
103
+ ])
104
+ }
105
+ else {
106
+ await this.#kv.transaction(() => [
107
+ this.#wip.write.del(info.id),
108
+ this.#trash.write.set(info.id, true),
109
+ ])
110
+ }
111
+ }
112
+ }
113
+
@@ -0,0 +1,7 @@
1
+
2
+ import {Hash} from "../types.js"
3
+
4
+ export function notFound(hash: Hash) {
5
+ return `file not found by hash "${hash}"`
6
+ }
7
+
@@ -0,0 +1,30 @@
1
+
2
+ import {hex} from "@e280/stz"
3
+ import {blake3} from "@noble/hashes/blake3.js"
4
+ import {Analysis, Bucket, Id} from "../types.js"
5
+
6
+ export async function save(
7
+ bucket: Bucket,
8
+ id: Id,
9
+ readable: ReadableStream<Uint8Array>,
10
+ ): Promise<Analysis> {
11
+
12
+ let size = 0
13
+ const pipe = new TransformStream()
14
+ const done = bucket.write(id, pipe.readable)
15
+ const writer = pipe.writable.getWriter()
16
+ const hasher = blake3.create()
17
+
18
+ for await (const chunk of readable) {
19
+ await writer.write(chunk as Uint8Array<ArrayBuffer>)
20
+ hasher.update(chunk)
21
+ size += chunk.byteLength
22
+ }
23
+
24
+ await writer.close()
25
+ await done
26
+
27
+ const hash = hex.fromBytes(hasher.digest())
28
+ return {hash, size}
29
+ }
30
+