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:
| Model | Table | Notes |
|---|---|---|
RoomModel | olympia_rooms | PK = room code; opts JSON-cast |
GuestSessionModel | olympia_guest_sessions | Anonymous sessions, 7-day TTL |
ReplayIndexModel | olympia_replay_index | D1 index over R2 replay binaries |
BannedIpHashModel | olympia_banned_ip_hashes | PK = 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: trueinstead ofINSERT OR REPLACE— the latter breaks foreign keys.- The CLI resolves the
DBbinding fromwrangler.jsoncitself:bunx d1-eloquent migrate, no flags.