A system for building static webapps
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(blobs): adds @civility/blobs

+423 -3
+137
packages/blobs/__tests__/blobs.test.ts
··· 1 + import { assertEquals, assertExists } from '@std/assert' 2 + import 'fake-indexeddb/auto' 3 + import { BlobStore } from '../mod.ts' 4 + import { IDBBlobStorage } from '../storage/idb.ts' 5 + 6 + function makeStorage(name?: string) { 7 + return new IDBBlobStorage({ 8 + dbName: name ?? `test-blobs-${crypto.randomUUID()}`, 9 + idb: globalThis.indexedDB, 10 + }) 11 + } 12 + 13 + function makeStore(name?: string) { 14 + return new BlobStore(makeStorage(name)) 15 + } 16 + 17 + Deno.test('BlobStore — put and get', async () => { 18 + const store = makeStore() 19 + const data = new Blob(['hello world'], { type: 'text/plain' }) 20 + 21 + const hash = await store.put(data, 'text/plain') 22 + 23 + assertEquals(hash.startsWith('sha256:'), true) 24 + assertEquals(hash.length, 7 + 64) // "sha256:" + 64 hex chars 25 + 26 + const retrieved = await store.get(hash) 27 + assertExists(retrieved) 28 + assertEquals(await retrieved.text(), 'hello world') 29 + }) 30 + 31 + Deno.test('BlobStore — same content produces same hash', async () => { 32 + const store = makeStore() 33 + const data1 = new Blob(['identical content']) 34 + const data2 = new Blob(['identical content']) 35 + 36 + const hash1 = await store.put(data1) 37 + const hash2 = await store.put(data2) 38 + 39 + assertEquals(hash1, hash2) 40 + }) 41 + 42 + Deno.test('BlobStore — different content produces different hashes', async () => { 43 + const store = makeStore() 44 + 45 + const hash1 = await store.put(new Blob(['content A'])) 46 + const hash2 = await store.put(new Blob(['content B'])) 47 + 48 + assertEquals(hash1 !== hash2, true) 49 + }) 50 + 51 + Deno.test('BlobStore — put with ArrayBuffer', async () => { 52 + const store = makeStore() 53 + const encoder = new TextEncoder() 54 + const buffer = encoder.encode('arraybuffer test').buffer as ArrayBuffer 55 + 56 + const hash = await store.put(buffer, 'application/octet-stream') 57 + const retrieved = await store.get(hash) 58 + assertExists(retrieved) 59 + assertEquals(await retrieved.text(), 'arraybuffer test') 60 + }) 61 + 62 + Deno.test('BlobStore — put with Uint8Array', async () => { 63 + const store = makeStore() 64 + const bytes = new TextEncoder().encode('uint8 test') 65 + 66 + const hash = await store.put(bytes, 'text/plain') 67 + const retrieved = await store.get(hash) 68 + assertExists(retrieved) 69 + assertEquals(await retrieved.text(), 'uint8 test') 70 + }) 71 + 72 + Deno.test('BlobStore — has', async () => { 73 + const store = makeStore() 74 + 75 + assertEquals(await store.has('sha256:nonexistent'), false) 76 + 77 + const hash = await store.put(new Blob(['exists'])) 78 + assertEquals(await store.has(hash), true) 79 + }) 80 + 81 + Deno.test('BlobStore — delete', async () => { 82 + const store = makeStore() 83 + const hash = await store.put(new Blob(['to delete'])) 84 + 85 + assertEquals(await store.has(hash), true) 86 + await store.delete(hash) 87 + assertEquals(await store.has(hash), false) 88 + assertEquals(await store.get(hash), null) 89 + }) 90 + 91 + Deno.test('BlobStore — list', async () => { 92 + const store = makeStore() 93 + 94 + const h1 = await store.put(new Blob(['one'])) 95 + const h2 = await store.put(new Blob(['two'])) 96 + const h3 = await store.put(new Blob(['three'])) 97 + 98 + const hashes = await store.list() 99 + assertEquals(hashes.length, 3) 100 + assertEquals(hashes.includes(h1), true) 101 + assertEquals(hashes.includes(h2), true) 102 + assertEquals(hashes.includes(h3), true) 103 + }) 104 + 105 + Deno.test('BlobStore — getMissing', async () => { 106 + const store = makeStore() 107 + 108 + const h1 = await store.put(new Blob(['present'])) 109 + const fakeMissing = 110 + 'sha256:0000000000000000000000000000000000000000000000000000000000000000' 111 + 112 + const missing = await store.getMissing([h1, fakeMissing]) 113 + assertEquals(missing.length, 1) 114 + assertEquals(missing[0], fakeMissing) 115 + }) 116 + 117 + Deno.test('BlobStore — getMeta', async () => { 118 + const store = makeStore() 119 + const data = new Blob(['meta test'], { type: 'text/plain' }) 120 + 121 + const hash = await store.put(data, 'text/plain') 122 + const meta = store.getMeta(hash) 123 + 124 + assertExists(meta) 125 + assertEquals(meta.hash, hash) 126 + assertEquals(meta.mime, 'text/plain') 127 + assertEquals(meta.size, 9) // "meta test" = 9 bytes 128 + assertExists(meta.createdAt) 129 + }) 130 + 131 + Deno.test('IDBBlobStorage — close', async () => { 132 + const storage = makeStorage() 133 + // Ensure it opens successfully by doing an operation 134 + assertEquals(await storage.has('sha256:test'), false) 135 + // Close should not throw 136 + storage.close() 137 + })
+5 -3
packages/blobs/deno.json
··· 1 1 { 2 2 "name": "@civility/blobs", 3 - "version": "0.0.1", 3 + "version": "1.0.0-beta", 4 4 "exports": { 5 - ".": "./mod.ts" 6 - } 5 + ".": "./mod.ts", 6 + "./idb": "./storage/idb.ts" 7 + }, 8 + "imports": {} 7 9 }
+136
packages/blobs/mod.ts
··· 1 + /** 2 + * @module @civility/blobs — Content-addressable blob storage 3 + * 4 + * Stores binary data (images, files, etc.) keyed by their SHA-256 hash. 5 + * Blobs are immutable once stored — the same content always produces 6 + * the same key. 7 + * 8 + * @example 9 + * ```ts 10 + * import { BlobStore } from '@civility/blobs' 11 + * import { IDBBlobStorage } from '@civility/blobs/idb' 12 + * 13 + * const storage = new IDBBlobStorage({ dbName: 'my-app-blobs' }) 14 + * const store = new BlobStore(storage) 15 + * 16 + * const hash = await store.put(file, 'image/png') 17 + * const blob = await store.get(hash) 18 + * ``` 19 + */ 20 + 21 + /** 22 + * Backend interface for blob persistence. 23 + * 24 + * Implementations store raw binary data keyed by content hash. 25 + * The hash is always a `sha256:` prefixed hex string. 26 + */ 27 + export interface BlobStorage { 28 + put(hash: string, data: Blob): Promise<void> 29 + get(hash: string): Promise<Blob | null> 30 + has(hash: string): Promise<boolean> 31 + delete(hash: string): Promise<void> 32 + list(): Promise<string[]> 33 + } 34 + 35 + /** Metadata stored alongside each blob. */ 36 + export interface BlobMeta { 37 + hash: string 38 + mime: string 39 + size: number 40 + createdAt: string 41 + } 42 + 43 + /** 44 + * Content-addressable blob store. 45 + * 46 + * Wraps a {@linkcode BlobStorage} backend and adds: 47 + * - Automatic SHA-256 hashing on `put()` 48 + * - MIME type and size metadata tracking 49 + * - Batch existence checks via `getMissing()` 50 + */ 51 + export class BlobStore { 52 + #storage: BlobStorage 53 + #meta: Map<string, BlobMeta> = new Map() 54 + 55 + constructor(storage: BlobStorage) { 56 + this.#storage = storage 57 + } 58 + 59 + /** 60 + * Store a blob and return its `sha256:<hex>` hash. 61 + * 62 + * If the blob already exists (same hash), this is a no-op 63 + * and returns the existing hash. 64 + */ 65 + async put( 66 + data: Blob | ArrayBuffer | Uint8Array, 67 + mime?: string, 68 + ): Promise<string> { 69 + const blob = data instanceof Blob ? data : new Blob([ 70 + data instanceof ArrayBuffer ? data : data.buffer as ArrayBuffer, 71 + ], { type: mime }) 72 + 73 + const buffer = await blob.arrayBuffer() 74 + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) 75 + const hex = Array.from(new Uint8Array(hashBuffer)) 76 + .map((b) => b.toString(16).padStart(2, '0')) 77 + .join('') 78 + const hash = `sha256:${hex}` 79 + 80 + if (await this.#storage.has(hash)) { 81 + return hash 82 + } 83 + 84 + const finalBlob = mime ? new Blob([buffer], { type: mime }) : blob 85 + await this.#storage.put(hash, finalBlob) 86 + 87 + this.#meta.set(hash, { 88 + hash, 89 + mime: mime ?? blob.type ?? 'application/octet-stream', 90 + size: buffer.byteLength, 91 + createdAt: new Date().toISOString(), 92 + }) 93 + 94 + return hash 95 + } 96 + 97 + /** Retrieve a blob by hash, or `null` if not found. */ 98 + async get(hash: string): Promise<Blob | null> { 99 + return await this.#storage.get(hash) 100 + } 101 + 102 + /** Check whether a blob exists. */ 103 + async has(hash: string): Promise<boolean> { 104 + return await this.#storage.has(hash) 105 + } 106 + 107 + /** Delete a blob by hash. */ 108 + async delete(hash: string): Promise<void> { 109 + await this.#storage.delete(hash) 110 + this.#meta.delete(hash) 111 + } 112 + 113 + /** List all stored blob hashes. */ 114 + async list(): Promise<string[]> { 115 + return await this.#storage.list() 116 + } 117 + 118 + /** 119 + * Given a list of hashes, return those that are NOT in local storage. 120 + * Useful for determining which blobs need to be fetched from a remote. 121 + */ 122 + async getMissing(hashes: string[]): Promise<string[]> { 123 + const missing: string[] = [] 124 + for (const hash of hashes) { 125 + if (!await this.#storage.has(hash)) { 126 + missing.push(hash) 127 + } 128 + } 129 + return missing 130 + } 131 + 132 + /** Get metadata for a blob (only available for blobs added via this instance). */ 133 + getMeta(hash: string): BlobMeta | undefined { 134 + return this.#meta.get(hash) 135 + } 136 + }
+145
packages/blobs/storage/idb.ts
··· 1 + /** 2 + * @module @civility/blobs/idb — IndexedDB blob storage backend 3 + * 4 + * Stores blobs in a single IndexedDB object store keyed by content hash. 5 + * 6 + * @example 7 + * ```ts 8 + * import { BlobStore } from '@civility/blobs' 9 + * import { IDBBlobStorage } from '@civility/blobs/idb' 10 + * 11 + * const storage = new IDBBlobStorage({ dbName: 'my-app-blobs' }) 12 + * const store = new BlobStore(storage) 13 + * ``` 14 + */ 15 + 16 + import type { BlobStorage } from '../mod.ts' 17 + 18 + /** Options for {@linkcode IDBBlobStorage}. */ 19 + export interface IDBBlobStorageOptions { 20 + /** Name of the IndexedDB database. */ 21 + dbName: string 22 + /** 23 + * IDB schema version. 24 + * @default 1 25 + */ 26 + version?: number 27 + /** 28 + * Inject a custom `IDBFactory` (e.g. `fake-indexeddb` for testing). 29 + * Defaults to `globalThis.indexedDB`. 30 + */ 31 + idb?: IDBFactory 32 + } 33 + 34 + const STORE_NAME = 'blobs' 35 + 36 + /** 37 + * IndexedDB-backed {@linkcode BlobStorage}. 38 + * 39 + * Each entry stores a `Blob` keyed by its `sha256:<hex>` hash. 40 + * The database is opened lazily on construction. 41 + */ 42 + export class IDBBlobStorage implements BlobStorage { 43 + readonly #dbName: string 44 + readonly #version: number 45 + readonly #idb: IDBFactory 46 + #db: IDBDatabase | null = null 47 + readonly #ready: Promise<void> 48 + 49 + constructor(opts: IDBBlobStorageOptions) { 50 + this.#dbName = opts.dbName 51 + this.#version = opts.version ?? 1 52 + this.#idb = opts.idb ?? globalThis.indexedDB 53 + this.#ready = this.#open() 54 + } 55 + 56 + async #open(): Promise<void> { 57 + this.#db = await new Promise<IDBDatabase>((resolve, reject) => { 58 + const request = this.#idb.open(this.#dbName, this.#version) 59 + 60 + request.onupgradeneeded = (event) => { 61 + const db = (event.target as IDBOpenDBRequest).result 62 + if (!db.objectStoreNames.contains(STORE_NAME)) { 63 + db.createObjectStore(STORE_NAME) 64 + } 65 + } 66 + 67 + request.onsuccess = () => resolve(request.result) 68 + request.onerror = () => reject(request.error) 69 + request.onblocked = () => reject(new Error('IDB open blocked')) 70 + }) 71 + } 72 + 73 + async #getDb(): Promise<IDBDatabase> { 74 + await this.#ready 75 + return this.#db! 76 + } 77 + 78 + async put(hash: string, data: Blob): Promise<void> { 79 + const db = await this.#getDb() 80 + // Store as { buffer, type } instead of raw Blob for cross-environment 81 + // compatibility (some IDB implementations don't round-trip Blobs). 82 + const buffer = await data.arrayBuffer() 83 + const entry = { buffer, type: data.type } 84 + await new Promise<void>((resolve, reject) => { 85 + const tx = db.transaction(STORE_NAME, 'readwrite') 86 + tx.oncomplete = () => resolve() 87 + tx.onerror = () => reject(tx.error) 88 + tx.objectStore(STORE_NAME).put(entry, hash) 89 + }) 90 + } 91 + 92 + async get(hash: string): Promise<Blob | null> { 93 + const db = await this.#getDb() 94 + const tx = db.transaction(STORE_NAME, 'readonly') 95 + const result = await new Promise< 96 + { buffer: ArrayBuffer; type: string } | undefined 97 + >( 98 + (resolve, reject) => { 99 + const request = tx.objectStore(STORE_NAME).get(hash) 100 + request.onsuccess = () => resolve(request.result) 101 + request.onerror = () => reject(request.error) 102 + }, 103 + ) 104 + if (result == null) return null 105 + return new Blob([result.buffer], { type: result.type }) 106 + } 107 + 108 + async has(hash: string): Promise<boolean> { 109 + const db = await this.#getDb() 110 + const tx = db.transaction(STORE_NAME, 'readonly') 111 + const result = await new Promise<IDBValidKey | undefined>( 112 + (resolve, reject) => { 113 + const request = tx.objectStore(STORE_NAME).getKey(hash) 114 + request.onsuccess = () => resolve(request.result) 115 + request.onerror = () => reject(request.error) 116 + }, 117 + ) 118 + return result !== undefined 119 + } 120 + 121 + async delete(hash: string): Promise<void> { 122 + const db = await this.#getDb() 123 + await new Promise<void>((resolve, reject) => { 124 + const tx = db.transaction(STORE_NAME, 'readwrite') 125 + tx.oncomplete = () => resolve() 126 + tx.onerror = () => reject(tx.error) 127 + tx.objectStore(STORE_NAME).delete(hash) 128 + }) 129 + } 130 + 131 + async list(): Promise<string[]> { 132 + const db = await this.#getDb() 133 + const tx = db.transaction(STORE_NAME, 'readonly') 134 + return new Promise<string[]>((resolve, reject) => { 135 + const request = tx.objectStore(STORE_NAME).getAllKeys() 136 + request.onsuccess = () => resolve(request.result as string[]) 137 + request.onerror = () => reject(request.error) 138 + }) 139 + } 140 + 141 + /** Close the database connection. */ 142 + close(): void { 143 + this.#db?.close() 144 + } 145 + }