Recipe: Gallery

Images are blobs, not JSON. Store the bytes in a BlobStore, keep a lightweight reference in a Collection, and let CloudStore move the bytes lazily so a phone doesn’t pull every full-res image at once. Assume the singleton app store around this.

// www/store/app.ts
import { BlobStore } from '@civility/blobs'
import { IDBBlobStorage } from '@civility/blobs/idb'
import { IDBStorage } from '@civility/store/idb'
import { CloudStore } from '@civility/cloud'

const APP_ID = 'me.example.gallery'

type Photo = { caption: string; hash: string; mime: string; size: number }

const blobs = new BlobStore(new IDBBlobStorage({ dbName: `${APP_ID}.blobs` }))
const cloud = new CloudStore(new IDBStorage({ dbName: APP_ID }), {
  collections: ['photos'],
  versions: [/* … */],
  cloud: {
    appId: APP_ID,
    blobStore: blobs, // blob bodies sync alongside change data
    blobPolicy: 'lazy', // metadata now, bytes on demand
  },
})

export class App {
  photos = cloud.collection<Photo>('photos')
  blobs = blobs
  cloud = cloud

  async addPhoto(file: File, caption: string) {
    const hash = await blobs.put(file, file.type) // "sha256:…", dedupes identical bytes
    await this.photos.set(crypto.randomUUID(), {
      caption,
      hash,
      mime: file.type,
      size: file.size,
    })
  }
}

Render a thumbnail by resolving the blob to an object URL. cloud.blob(hash) fetches the body from the server on demand if this device doesn’t have it yet:

async function thumbUrl(hash: string): Promise<string> {
  const local = await app.blobs.get(hash)
  const blob = local ?? await app.cloud.blob(hash) // lazy fetch if missing
  return URL.createObjectURL(blob)
}

For <img src> at scale, prefer the withBlobProxy service-worker plugin — plain <img src="/blob/${hash}">, no object-URL bookkeeping. Reclaim space for deleted photos with cloud.compactBlobs().


Next: AI-powered turns free text into structured records with @civility/services.