hcengineering/platform · error · ReadonlyError

ReadonlyError

Error message

ReadonlyError

What it means

ReadonlyStorageAdapter wraps a real StorageAdapter and deliberately throws ReadonlyError('Readonly mode') from mutating methods. make() (which creates/initializes a workspace bucket/prefix) is one of them. The wrapper is enabled when a storage config has readonly=true (see createStorageFromConfig). It is an intentional policy rejection, not a bug — all writes are blocked while reads delegate to the underlying adapter.

Source

Thrown at foundations/server/packages/server-storage/src/readonly.ts:43

}

export class ReadonlyStorageAdapter implements StorageAdapter {
  constructor (private readonly adapter: StorageAdapter) {}

  async initialize (ctx: MeasureContext, wsIds: WorkspaceIds): Promise<void> {
    await this.adapter.initialize(ctx, wsIds)
  }

  async close (): Promise<void> {
    await this.adapter.close()
  }

  async exists (ctx: MeasureContext, wsIds: WorkspaceIds): Promise<boolean> {
    return await this.adapter.exists(ctx, wsIds)
  }

  async make (ctx: MeasureContext, wsIds: WorkspaceIds): Promise<void> {
    throw new ReadonlyError()
  }

  async listBuckets (ctx: MeasureContext): Promise<BucketInfo[]> {
    return await this.adapter.listBuckets(ctx)
  }

  async delete (ctx: MeasureContext, wsIds: WorkspaceIds): Promise<void> {
    throw new ReadonlyError()
  }

  async remove (ctx: MeasureContext, wsIds: WorkspaceIds, objectNames: string[]): Promise<void> {
    throw new ReadonlyError()
  }

  async listStream (ctx: MeasureContext, wsIds: WorkspaceIds): Promise<BlobStorageIterator> {
    return await this.adapter.listStream(ctx, wsIds)
  }

View on GitHub (pinned to 63e28dc964)

Solutions

  1. Remove the readonly flag from the storage URI in STORAGE_CONFIG (e.g. drop &readonly=true) for the storage that must accept writes
  2. If the bucket genuinely must stay read-only, create the workspace in a different writable storage and mount the readonly one only as a fallback/read source
  3. Verify which adapter throws by logging config.kind/name before createStorageFromConfig
  4. Check that a recent deployment didn't flip readonly=true unintentionally

Example fix

// before
STORAGE_CONFIG=minio|minio:9000?accessKey=minio&secretKey=minio&readonly=true
// after
STORAGE_CONFIG=minio|minio:9000?accessKey=minio&secretKey=minio
Defensive patterns

Strategy: validation

Validate before calling

// before calling make(), ensure the target storage is writable
const cfg = storageConfig.storages.find(s => s.name === targetName)
if (cfg?.readonly === 'true') {
  throw new Error(`storage ${targetName} is readonly; make() would fail`)
}

Type guard

function isReadonlyStorageAdapter (a: StorageAdapter): a is ReadonlyStorageAdapter {
  return a instanceof ReadonlyStorageAdapter
}

Try / catch

try {
  await storage.make(ctx, wsIds)
} catch (err) {
  if (err.name === 'ReadonlyError') {
    // storage is intentionally immutable; create workspace elsewhere or skip
    return skipWithWarning(wsIds)
  }
  throw err
}

Prevention

When it happens

Trigger: Any code path calling make(ctx, wsIds) on a ReadonlyStorageAdapter — typically server startup or first access to a workspace that doesn't exist yet in a storage configured with readonly=true in STORAGE_CONFIG (e.g. appending '|readonly=true' or setting the readonly query param on the URI), or programmatic use of ReadonlyStorageAdapter as a wrapper.

Common situations: Pointing a full (writable) service at a read-only replica/backup bucket by mistake; migrating between storages where the new primary is mistakenly marked readonly; copy-pasting a STORAGE_CONFIG example that included readonly=true; disaster-recovery runbook executed with the read-only flag still set.

Related errors


AI-assisted analysis of hcengineering/platform@63e28dc964 (2026-08-29). Data as JSON: /api/errors/4d98e91e9a2c2da1. Report an issue: GitHub.