Type-safe ORM for Cloudflare Durable Objects with zero runtime overhead
DO-ORM makes Durable Objects queryable like a real database while maintaining the performance and simplicity of Cloudflare's storage API. Built with pure TypeScript, zero dependencies, and automatic schema validation.
Type-safe schema definitions - Full TypeScript inference for all CRUD operations
Automatic validation - Schema validation on every write operation
Efficient indexing - Single-field indexes for O(log n) queries instead of O(n) scans
Fluent query builder - Chain .where(), .after(), .before(), .limit(), .orderBy()
Full CRUD support - create(), find(), update(), delete(), and bulk operations
Zero dependencies - Pure TypeScript using DO storage primitives
Zero runtime overhead - Direct wrapper around Durable Objects storage API
npm install @hammr/do-ormimport { DOModel, SchemaDefinition, InferSchemaType } from '@hammr/do-orm';
// Define schema with type annotations
interface EventSchema extends SchemaDefinition {
id: 'string';
workspaceId: 'string';
timestamp: 'date';
type: 'string';
data: 'object';
}
// Create model class
class Event extends DOModel<EventSchema> {
protected schema: EventSchema = {
id: 'string',
workspaceId: 'string',
timestamp: 'date',
type: 'string',
data: 'object',
};
// Define indexes for efficient queries
protected indexes = ['workspaceId', 'timestamp'] as const;
}export class MyDurableObject {
private eventModel: Event;
constructor(state: DurableObjectState) {
this.eventModel = new Event(state.storage);
}
async fetch(request: Request): Promise<Response> {
// Create an event
const event = await this.eventModel.create({
id: 'evt_123',
workspaceId: 'ws_abc',
timestamp: new Date(),
type: 'click',
data: { button: 'submit' }
});
// Query events
const recentEvents = await this.eventModel
.where({ workspaceId: 'ws_abc' })
.after(new Date('2024-01-01'))
.limit(100)
.orderBy('timestamp', 'desc')
.execute();
return new Response(JSON.stringify(recentEvents));
}
}DO-ORM supports the following field types:
'string'- String values'number'- Numeric values (integers and floats)'boolean'- Boolean values (true/false)'date'- Date objects (automatically serialized/deserialized)'object'- Plain JavaScript objects'array'- Arrays of any type
Create a new record. Throws if validation fails or ID already exists.
const event = await eventModel.create({
id: 'evt_1',
workspaceId: 'ws_abc',
timestamp: new Date(),
type: 'pageview',
data: { page: '/home' }
});Find a record by ID. Returns null if not found.
const event = await eventModel.find('evt_1');
if (event) {
console.log(event.type); // Type-safe access
}Update a record with partial data. Validates the complete merged record.
const updated = await eventModel.update('evt_1', {
data: { page: '/about' }
});Delete a record by ID. Returns true if deleted, false if not found.
const deleted = await eventModel.delete('evt_1');Get all records (unfiltered).
const allEvents = await eventModel.all();Count all records.
const totalEvents = await eventModel.count();Chain query methods for powerful filtering and sorting:
Filter by field values. Uses indexes when available.
const events = await eventModel
.where({ workspaceId: 'ws_abc' })
.execute();Filter records with date fields after the specified date.
const recentEvents = await eventModel
.after(new Date('2024-01-01'))
.execute();Filter records with date fields before the specified date.
const oldEvents = await eventModel
.before(new Date('2023-12-31'))
.execute();Limit the number of results returned.
const topEvents = await eventModel
.where({ workspaceId: 'ws_abc' })
.limit(10)
.execute();Sort results by a field.
const sortedEvents = await eventModel
.where({ workspaceId: 'ws_abc' })
.orderBy('timestamp', 'desc')
.execute();Execute the query and return results.
const events = await eventModel
.where({ workspaceId: 'ws_abc' })
.limit(100)
.execute();const events = await eventModel
.where({ workspaceId: 'ws_abc' })
.after(new Date('2024-01-01'))
.before(new Date('2024-12-31'))
.orderBy('timestamp', 'desc')
.limit(50)
.execute();Indexes dramatically improve query performance by avoiding full table scans:
- Without index: O(n) - scans every record
- With index: O(log n) - uses sorted index lookup
class Event extends DOModel<EventSchema> {
protected schema: EventSchema = {
id: 'string',
workspaceId: 'string',
timestamp: 'date',
type: 'string',
};
// Index these fields for efficient queries
protected indexes = ['workspaceId', 'timestamp'] as const;
}.where({ indexedField: value })- Uses index if first field is indexed- Without indexed where clause - Falls back to full scan
Indexes are automatically maintained:
- Created during
create() - Updated during
update()(if indexed fields change) - Removed during
delete()
DO-ORM validates all data against your schema:
// ✅ Valid - passes validation
await eventModel.create({
id: 'evt_1',
workspaceId: 'ws_abc',
timestamp: new Date(),
type: 'click',
data: {}
});
// ❌ Invalid - throws error
await eventModel.create({
id: 'evt_1',
workspaceId: 123, // Error: must be string
timestamp: new Date(),
type: 'click',
data: {}
});
// ❌ Invalid - throws error
await eventModel.create({
id: 'evt_1',
// Missing required fields
});try {
await eventModel.create(invalidData);
} catch (error) {
// "Field 'workspaceId' must be a string, got number"
// "Missing required field: timestamp"
}DO-ORM provides full type inference:
// Define schema
interface EventSchema extends SchemaDefinition {
id: 'string';
workspaceId: 'string';
timestamp: 'date';
}
class Event extends DOModel<EventSchema> {
protected schema: EventSchema = {
id: 'string',
workspaceId: 'string',
timestamp: 'date',
};
protected indexes = ['workspaceId'] as const;
}
// TypeScript knows the exact type!
const event = await eventModel.find('evt_1');
// ^? Event | null
if (event) {
event.id; // string
event.workspaceId; // string
event.timestamp; // Date
event.unknown; // ❌ TypeScript error
}class Event extends DOModel<EventSchema> {
constructor(storage: DurableObjectStorage) {
super(storage, 'custom_events_table');
}
protected schema: EventSchema = { /* ... */ };
protected indexes = [] as const;
}export class MyDurableObject {
private events: Event;
private users: User;
constructor(state: DurableObjectState) {
this.events = new Event(state.storage);
this.users = new User(state.storage, 'users_table');
}
async fetch(request: Request): Promise<Response> {
const event = await this.events.find('evt_1');
const user = await this.users.find('user_1');
// ...
}
}- Indexed queries: Fast O(log n) lookups
- Non-indexed queries: Slower O(n) full scans
- Best practice: Index frequently queried fields
- Records stored as:
{tableName}:{id} - Indexes stored as:
index:{tableName}:{field}:{value} - Dates serialized as ISO strings for efficient sorting
- Use indexes - Define indexes for frequently queried fields
- Limit results - Always use
.limit()for large datasets - Specific where clauses - Filter by indexed fields first
- Batch operations - Consider batching writes for bulk inserts
- No compound indexes - Only single-field indexes (for now)
- No transactions - Each operation is atomic but not grouped
- No joins - Each model is independent
- No migrations - Schema changes require manual data migration
interface AnalyticsSchema extends SchemaDefinition {
id: 'string';
sessionId: 'string';
userId: 'string';
event: 'string';
timestamp: 'date';
properties: 'object';
}
class Analytics extends DOModel<AnalyticsSchema> {
protected schema: AnalyticsSchema = {
id: 'string',
sessionId: 'string',
userId: 'string',
event: 'string',
timestamp: 'date',
properties: 'object',
};
protected indexes = ['userId', 'sessionId', 'timestamp'] as const;
}
// Track an event
await analytics.create({
id: generateId(),
sessionId: 'session_abc',
userId: 'user_123',
event: 'purchase',
timestamp: new Date(),
properties: { amount: 99.99, currency: 'USD' }
});
// Get user's recent events
const userEvents = await analytics
.where({ userId: 'user_123' })
.after(thirtyDaysAgo)
.orderBy('timestamp', 'desc')
.limit(100)
.execute();interface TaskSchema extends SchemaDefinition {
id: 'string';
status: 'string';
priority: 'number';
createdAt: 'date';
payload: 'object';
}
class Task extends DOModel<TaskSchema> {
protected schema: TaskSchema = {
id: 'string',
status: 'string',
priority: 'number',
createdAt: 'date',
payload: 'object',
};
protected indexes = ['status', 'priority'] as const;
}
// Add task
await task.create({
id: 'task_1',
status: 'pending',
priority: 1,
createdAt: new Date(),
payload: { action: 'send_email' }
});
// Get pending tasks
const pending = await task
.where({ status: 'pending' })
.orderBy('priority', 'asc')
.limit(10)
.execute();
// Process and mark complete
for (const t of pending) {
await processTask(t);
await task.update(t.id, { status: 'completed' });
}Run the unit test suite:
npm testTests include:
- Schema validation (type checking, required fields)
- CRUD operations (create, read, update, delete)
- Query builder (where, limit, orderBy, date ranges)
- Index usage and maintenance
- Edge cases (duplicates, missing records)
Test the ORM in a real Cloudflare Workers environment:
# Terminal 1: Start the worker
npm run dev
# Terminal 2: Run integration tests
npm run test:workerThe integration tests verify the complete stack:
- Worker HTTP endpoints
- Durable Object instantiation
- DO-ORM with real DO storage
- Schema validation in production
- Query performance with indexes
See TESTING.md for more details on testing with Cloudflare Workers.
This is v1 - there's lots of room for improvement!
Potential enhancements:
- Compound indexes (multiple fields)
- Transactions support
- Query result streaming
- Migration helpers
- Soft deletes
- Hooks (beforeCreate, afterUpdate, etc.)
Apache-2.0
Built for the Cloudflare Workers ecosystem. Works seamlessly with Durable Objects and provides a better developer experience than raw storage API calls.