Custom adapters

Sobj is designed to be highly modular. Connecting to a custom backend — such as local filesystem storage, an internal server API, or a custom database — is as swift as writing a plain JavaScript object.

🦴 Anatomy of an Adapter

An adapter is a function or helper that returns an object satisfying the SobjAdapter contract. It consists of two parts:

  1. Capabilities definition: A compile-time assertion declaring which operations the adapter supports.
  2. Operations implementation: Asynchronous functions translating Sobj calls to your backend's operations.

🧪 Example: Building a LocalStorage Adapter

Here is a fully functional custom adapter mapping Sobj keys to the browser's localStorage.

📋 1. Define Capabilities

Capabilities register what operations your adapter implements. Required methods (put, get, head, delete, list) must be marked true. Optional features (like copy, presign, multipart) can be marked false.

ts
import type { SobjAdapter, SobjCapabilities } from 'sobj/adapter'

const capabilities = {
  copy: false,
  delete: true,
  get: true,
  head: true,
  list: true,
  multipart: false,
  presign: false,
  put: true,
  versioning: false,
} as const satisfies SobjCapabilities

type LocalCapabilities = typeof capabilities

⚙️ 2. Implement the Operations

Implement the functions matching your capabilities.

ts
import type { SobjObject, SobjMetadata, SobjList } from 'sobj/types'

const localStorageAdapter: SobjAdapter<LocalCapabilities> = {
  async put(key, body, options) {
    // Standardize input body to a string for local storage
    const content = typeof body === 'string' ? body : new TextDecoder().decode(body as any)
    localStorage.setItem(key, content)
    
    return {
      key,
      size: content.length,
      contentType: options?.contentType ?? 'text/plain',
    } satisfies SobjMetadata
  },

  async get(key) {
    const value = localStorage.getItem(key)
    if (value === null) return null // Return null if not found (don't throw)

    const encoder = new TextEncoder()
    const stream = new ReadableStream({
      start(controller) {
        controller.enqueue(encoder.encode(value))
        controller.close()
      }
    })

    return {
      body: stream,
      metadata: {
        key,
        size: value.length,
      }
    } satisfies SobjObject
  },

  async head(key) {
    const value = localStorage.getItem(key)
    if (value === null) return null

    return {
      key,
      size: value.length,
    } satisfies SobjMetadata
  },

  async delete(key) {
    localStorage.removeItem(key)
  },

  async list(options) {
    const keys = Object.keys(localStorage).filter(k => 
      options?.prefix ? k.startsWith(options.prefix) : true
    )

    const objects = keys.map(k => ({
      key: k,
      size: localStorage.getItem(k)?.length ?? 0
    }))

    return {
      objects,
      prefixes: [],
      hasMore: false,
    } satisfies SobjList
  }
}

🏭 3. Consume the adapter

ts
import { sobj } from 'sobj'

// Consume it!
const storage = sobj(localStorageAdapter)
await storage.put('theme', 'dark')

⚠️ Error Handling in Adapters

When an active operation detects a structural failure (e.g. invalid parameters or authorization errors), it should throw a designated SobjError subclass. This allows the host application to catch errors in a provider-agnostic way:

ts
import { SobjPermissionError, SobjNetworkError } from 'sobj/errors'

// If auth token expired in your custom backend api:
throw new SobjPermissionError('Invalid token credentials')

// If fetch request timed out:
throw new SobjNetworkError('Server unreachable')

For more details on error matching, read the Errors Reference.