Type-safe ORM

Every D1 access in the API goes through @orphnet/d1-eloquent — an Eloquent-style ORM built for Workers. No raw prepared statements in handlers, no string-built SQL, and every row is typed end to end. (Schema changes are covered separately in Migrations.)

Models

A model is a class with a typed attribute interface. Olympia ships four:

ModelTableNotes
RoomModelolympia_roomsPK = room code; opts JSON-cast
GuestSessionModelolympia_guest_sessionsAnonymous sessions, 7-day TTL
ReplayIndexModelolympia_replay_indexD1 index over R2 replay binaries
BannedIpHashModelolympia_banned_ip_hashesPK = HMAC-SHA256 digest
import { BaseModel } from '@orphnet/d1-eloquent'

export interface RoomAttrs {
  [key: string]: unknown
  id: string // room code
  game: Game
  status: RoomStatus
  player_count: number
  expires_at: number
  // ...
}

export class RoomModel extends BaseModel<RoomAttrs> {
  static table = 'olympia_rooms'
  static casts = { opts: 'json' } as const
}

Attribute casting means room.get('opts') returns a parsed object, never a JSON string — and writes serialize it back automatically.

The query builder

Real code from the 5-minute purge cron — batch updates and FK-safe deletes without a line of SQL:

// Mark expired rooms in one statement.
await RoomModel.query()
  .where('expires_at', '<', now)
  .where('status', '!=', 'expired')
  .where('status', '!=', 'completed')
  .limit(BATCH_LIMIT)
  .update(env.DB, { status: 'expired', updated_at: new Date().toISOString() })

// Collect R2 keys BEFORE deleting their index rows.
const replays = await ReplayIndexModel.query()
  .where('expires_at', '<', now)
  .limit(BATCH_LIMIT)
  .get(env.DB)

await ReplayIndexModel.query()
  .whereIn('id', replays.map(r => r.get('id')))
  .delete(env.DB)

The full surface includes whereIn / whereLike / whereNull / whereBetween, named scopes, aggregates (count, sum, …), and paginate(page, perPage).

CRUD

const session = await GuestSessionModel.create(db, attrs)
const room = await RoomModel.find(db, code)        // null if missing
await RoomModel.findOrFail(db, code)               // throws ModelNotFoundException
room.set({ status: 'completed' })
await room.save(db)

House rules

  • IDs are TEXT — room codes and UUIDs, never integer autoincrement.
  • Check model._persisted, not a null PK, to know if a row exists.
  • upsert: true instead of INSERT OR REPLACE — the latter breaks foreign keys.
  • The CLI resolves the DB binding from wrangler.jsonc itself: bunx d1-eloquent migrate, no flags.