MongoDB-like Document Store built on Cloudflare Durable Objects with SQLite storage
npm install @delta-base/do-document-storeA MongoDB-like Document Store built on Cloudflare Durable Objects with SQLite storage.
- MongoDB-like API - Familiar insertOne, find, updateOne, deleteOne operations
- IReadModelStore Support - Adapter for event sourcing projections with testable interface
- Type-safe - Full TypeScript support with generics for document types
- SQLite-backed - Leverages Cloudflare's SQLite storage in Durable Objects
- Deploy in your account - Export the DO class and deploy to your own Cloudflare account
- Optimistic concurrency - Built-in _version field for conflict detection
- Soft deletes - Optional soft delete support with _archived flag
- RPC-friendly - Flat methods for direct DO calls over Cloudflare RPC
``bash`
npm install @delta-base/do-document-storeor
pnpm add @delta-base/do-document-store
`jsonc`
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2024-12-01",
"durable_objects": {
"bindings": [
{
"name": "DOCUMENT_STORE",
"class_name": "DocumentStoreDurableObject"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DocumentStoreDurableObject"]
}
]
}
`typescript
// src/index.ts
export { DocumentStoreDurableObject } from '@delta-base/do-document-store';
interface Env {
DOCUMENT_STORE: DurableObjectNamespace
}
export default {
async fetch(request: Request, env: Env) {
const id = env.DOCUMENT_STORE.idFromName('my-store');
const store = env.DOCUMENT_STORE.get(id);
// Use the document store
const users = store.collection
// ...
}
};
`
`typescript`
interface User {
name: string;
email: string;
age: number;
tags?: string[];
}
`typescript
const users = store.collection
// Insert
const { insertedId } = users.insertOne({
name: 'Alice',
email: 'alice@example.com',
age: 30,
tags: ['developer']
});
// Find
const alice = users.findOne({ email: 'alice@example.com' });
const adults = users.find({ age: { $gte: 18 } });
// Update
users.updateOne(
{ _id: insertedId },
{ $set: { age: 31 }, $push: { tags: 'senior' } }
);
// Delete
users.deleteOne({ _id: insertedId });
`
The main Durable Object class that provides document store functionality.
#### Methods
| Method | Description |
|--------|-------------|
| collection | Get a typed collection |createCollection(name)
| | Create a collection (called automatically) |dropCollection(name)
| | Drop a collection and all its data |listCollections()
| | List all collection names |hasCollection(name)
| | Check if a collection exists |
A typed collection for document operations.
#### Insert Operations
`typescript
// Insert one document
const result = users.insertOne({ name: 'Alice', email: 'a@b.com', age: 30 });
// Returns: { acknowledged: true, insertedId: 'uuid-...' }
// Insert many documents
const result = users.insertMany([
{ name: 'Alice', email: 'a@b.com', age: 30 },
{ name: 'Bob', email: 'b@b.com', age: 25 }
]);
// Returns: { acknowledged: true, insertedCount: 2, insertedIds: ['...', '...'] }
`
#### Find Operations
`typescript
// Find one document
const user = users.findOne({ email: 'alice@example.com' });
// Find many documents
const adults = users.find({ age: { $gte: 18 } });
// Find with options
const page = users.find(
{ status: 'active' },
{ limit: 10, offset: 20, sort: { createdAt: -1 } }
);
// Count documents
const count = users.countDocuments({ age: { $gt: 25 } });
`
#### Update Operations
`typescript
// Update one document
users.updateOne(
{ _id: 'some-id' },
{ $set: { name: 'New Name' }, $inc: { loginCount: 1 } }
);
// Update many documents
users.updateMany(
{ status: 'pending' },
{ $set: { status: 'active' } }
);
// Replace entire document
users.replaceOne(
{ _id: 'some-id' },
{ name: 'Alice', email: 'new@email.com', age: 31 }
);
// Upsert (insert if not exists)
users.updateOne(
{ email: 'new@user.com' },
{ $set: { name: 'New User' } },
{ upsert: true }
);
`
#### Delete Operations
`typescript
// Hard delete
users.deleteOne({ _id: 'some-id' });
// Soft delete (sets _archived = 1)
users.deleteOne({ _id: 'some-id' }, { softDelete: true });
// Delete many
users.deleteMany({ status: 'inactive' });
`
#### Collection Operations
`typescript
// Rename collection
users.rename('members');
// Drop collection
users.drop();
`
The package provides DOReadModelStore, an adapter that implements the IReadModelStore interface from @delta-base/toolkit. This enables:
- Testability: Projections can depend on IReadModelStore and use InMemoryReadModelStore for unit tests
- Flexibility: Same projection code works with different storage backends
- Event Sourcing: Clean integration with the command → projection → query lifecycle
`typescript
import { DOReadModelStore } from '@delta-base/do-document-store';
// Get the DO stub
const doId = env.DOCUMENT_STORE.idFromName('my-store');
const stub = env.DOCUMENT_STORE.get(doId);
// Create the adapter
const store = new DOReadModelStore(stub);
// Use as IReadModelStore
await store.put('user:123', { name: 'Alice', email: 'alice@test.com' });
const user = await store.get('user:123');
await store.delete('user:123');
`
`typescript
import type { IReadModelStore } from '@delta-base/toolkit';
// Projection depends on interface, not concrete implementation
class UserProjection {
constructor(private store: IReadModelStore) {}
async apply(event: UserCreatedEvent) {
await this.store.put(user:${event.userId}, {
id: event.userId,
name: event.name,
email: event.email,
});
}
}
// Unit test with InMemory store
import { InMemoryReadModelStore } from '@delta-base/toolkit';
const store = new InMemoryReadModelStore();
const projection = new UserProjection(store);
await projection.apply(mockEvent);
expect(await store.get('user:123')).toEqual({ ... });
// Production with DO-backed store
const stub = env.DOCUMENT_STORE.get(doId);
const store = new DOReadModelStore(stub);
const projection = new UserProjection(store);
`
Use the tableName option to store data in different collections:
`typescript
const store = new DOReadModelStore(stub);
// Store in different collections
await store.put('user:123', userData, { tableName: 'users' });
await store.put('order:456', orderData, { tableName: 'orders' });
// Retrieve from specific collection
const user = await store.get('user:123', { tableName: 'users' });
`
For complex queries that need the full MongoDB-like API, use getDocumentStore():
`typescript
const store = new DOReadModelStore(stub);
// Simple operations via IReadModelStore
await store.put('user:123', userData);
const user = await store.get('user:123');
// Rich queries via DocumentStore escape hatch
const docStore = store.getDocumentStore();
const activeUsers = await docStore.find('users',
{ status: 'active', age: { $gte: 18 } },
{ sort: { createdAt: -1 }, limit: 10 }
);
`
| Method | Description |
|--------|-------------|
| put(key, value, options?) | Store a value by key (upserts) |get(key, options?)
| | Retrieve a value by key |delete(key, options?)
| | Delete a value by key |getAll(options?)
| | Get all values, optionally filtered by prefix |listKeys(options?)
| | List keys with pagination support |batchGet(keys, options?)
| | Retrieve multiple values by keys |batchPut(items, options?)
| | Store multiple key-value pairs |batchDelete(keys, options?)
| | Delete multiple keys |getCapabilities()
| | Get store capabilities |getDocumentStore()
| | Access underlying DO stub for rich queries |
`typescript
import { DOReadModelStore } from '@delta-base/do-document-store';
import { createMiddleware } from 'hono/factory';
export const readModelStoreMiddleware = createMiddleware(async (c, next) => {
const orgId = c.get('orgId'); // From auth middleware
const doId = c.env.DOCUMENT_STORE.idFromName(orgId);
const stub = c.env.DOCUMENT_STORE.get(doId);
c.set('readModelStore', new DOReadModelStore(stub));
await next();
});
// Usage in routes
app.get('/users/:id', async (c) => {
const store = c.get('readModelStore');
const user = await store.get(user:${c.req.param('id')});`
return c.json(user);
});
The DocumentStoreDurableObject also exposes flat RPC-friendly methods for direct access without going through the collection() method. These are useful when calling the DO over RPC from a Worker:
`typescript
const stub = env.DOCUMENT_STORE.get(doId);
// Direct RPC calls (instead of stub.collection('users').findOne(...))
const user = await stub.findOne('users', { _id: 'user:123' });
const users = await stub.find('users', { status: 'active' }, { limit: 10 });
await stub.insertOne('users', { name: 'Alice', age: 30 });
await stub.updateOne('users', { _id: 'user:123' }, { $set: { age: 31 } });
await stub.deleteOne('users', { _id: 'user:123' });
`
| Method | Description |
|--------|-------------|
| findOne(collection, filter?) | Find a single document |find(collection, filter?, options?)
| | Find multiple documents |insertOne(collection, doc, options?)
| | Insert a document |insertMany(collection, docs, options?)
| | Insert multiple documents |updateOne(collection, filter, update, options?)
| | Update a single document |updateMany(collection, filter, update, options?)
| | Update multiple documents |replaceOne(collection, filter, doc, options?)
| | Replace a document |deleteOne(collection, filter, options?)
| | Delete a single document |deleteMany(collection, filter, options?)
| | Delete multiple documents |countDocuments(collection, filter?)
| | Count matching documents |
Supported MongoDB-style filter operators:
| Operator | Description | Example |
|----------|-------------|---------|
| $eq | Equal | { age: { $eq: 30 } } |$ne
| | Not equal | { status: { $ne: 'deleted' } } |$gt
| | Greater than | { age: { $gt: 18 } } |$gte
| | Greater than or equal | { age: { $gte: 21 } } |$lt
| | Less than | { age: { $lt: 65 } } |$lte
| | Less than or equal | { age: { $lte: 100 } } |$in
| | In array | { status: { $in: ['active', 'pending'] } } |$nin
| | Not in array | { role: { $nin: ['admin'] } } |$exists
| | Field exists | { email: { $exists: true } } |$and
| | Logical AND | { $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] } |$or
| | Logical OR | { $or: [{ status: 'active' }, { role: 'admin' }] } |
Use dot notation for nested fields:
`typescript`
users.find({ 'address.city': 'New York' });
users.updateOne({ _id: 'id' }, { $set: { 'profile.bio': 'Hello' } });
Supported MongoDB-style update operators:
| Operator | Description | Example |
|----------|-------------|---------|
| $set | Set field value | { $set: { name: 'Bob' } } |$unset
| | Remove field | { $unset: { oldField: '' } } |$inc
| | Increment number | { $inc: { count: 1 } } |$push
| | Add to array | { $push: { tags: 'new-tag' } } |$pull
| | Remove from array | { $pull: { tags: 'old-tag' } } |
Every document includes these system fields:
| Field | Type | Description |
|-------|------|-------------|
| _id | string | Primary key (auto-generated UUID if not provided) |_version
| | number | Incremented on each update (starts at 1) |_created
| | string | ISO timestamp of creation |_updated
| | string | ISO timestamp of last update |
The document store supports optimistic concurrency via the expectedVersion option. This allows you to prevent lost updates when multiple clients modify the same document.
| Value | Description |
|-------|-------------|
| number | Exact version the document must have |'DOCUMENT_DOES_NOT_EXIST'
| | Document must not exist (for inserts) |'DOCUMENT_EXISTS'
| | Document must exist (for updates/deletes, any version) |'NO_CONCURRENCY_CHECK'
| | Skip version checking (default) |
`typescript
// Ensure document doesn't already exist
users.insertOne(
{ _id: 'user-123', name: 'Alice', email: 'alice@example.com', age: 30 },
{ expectedVersion: 'DOCUMENT_DOES_NOT_EXIST' }
);
// Throws VersionMismatchError if document with _id 'user-123' already exists
`
`typescript
// Read document
const user = users.findOne({ _id: 'user-123' });
// user._version === 1
// Update only if version matches
users.updateOne(
{ _id: 'user-123' },
{ $set: { age: 31 } },
{ expectedVersion: 1 }
);
// Throws VersionMismatchError if another client modified the document
`
`typescript`
users.replaceOne(
{ _id: 'user-123' },
{ name: 'Alice Updated', email: 'new@example.com', age: 31 },
{ expectedVersion: 1 }
);
`typescript
users.deleteOne(
{ _id: 'user-123' },
{ expectedVersion: 2 }
);
// Works with soft delete too
users.deleteOne(
{ _id: 'user-123' },
{ softDelete: true, expectedVersion: 2 }
);
`
Use 'DOCUMENT_EXISTS' when you want to ensure a document exists before updating or deleting, but don't care about the specific version:
`typescript
// Update only if document exists (fails if not found)
users.updateOne(
{ _id: 'user-123' },
{ $set: { lastSeen: new Date().toISOString() } },
{ expectedVersion: 'DOCUMENT_EXISTS' }
);
// Delete only if document exists (fails if not found)
users.deleteOne(
{ _id: 'user-123' },
{ expectedVersion: 'DOCUMENT_EXISTS' }
);
// Throws VersionMismatchError if document doesn't exist
`
This is useful when you want to distinguish between "no document matched the filter" and "document was successfully modified" - without DOCUMENT_EXISTS, these operations silently return with matchedCount: 0 or deletedCount: 0.
`typescript
import {
VersionMismatchError,
isVersionMismatchError
} from '@delta-base/do-document-store';
try {
users.updateOne(
{ _id: 'user-123' },
{ $set: { age: 31 } },
{ expectedVersion: 1 }
);
} catch (error) {
if (isVersionMismatchError(error)) {
console.log('Conflict detected!');
console.log('Expected version:', error.expectedVersion);
console.log('Actual version:', error.actualVersion);
console.log('Document ID:', error.documentId);
// Handle conflict: reload document and retry, or notify user
}
}
`
For updateMany and deleteMany, the expectedVersion acts as an additional filter. Only documents matching both the filter AND the version will be affected:
`typescript
// Only updates documents at version 1
const result = users.updateMany(
{ status: 'pending' },
{ $set: { status: 'active' } },
{ expectedVersion: 1 }
);
// result.matchedCount shows how many matched both filter AND version
`
If NO documents match (but some exist at different versions), a VersionMismatchError is thrown.
Each collection is stored as a SQLite table:
`sql`
CREATE TABLE collection_name (
_id TEXT PRIMARY KEY,
data JSON NOT NULL,
metadata JSON NOT NULL DEFAULT '{}',
_version INTEGER NOT NULL DEFAULT 1,
_archived INTEGER NOT NULL DEFAULT 0,
_created TEXT NOT NULL DEFAULT (datetime('now')),
_updated TEXT NOT NULL DEFAULT (datetime('now'))
);
`typescript
import {
DuplicateKeyError,
DocumentNotFoundError,
InvalidFilterError,
isDuplicateKeyError
} from '@delta-base/do-document-store';
try {
users.insertOne({ _id: 'existing-id', ... });
} catch (error) {
if (isDuplicateKeyError(error)) {
console.log('Document already exists:', error.documentId);
}
}
`
Tests use the @cloudflare/vitest-pool-workers package:
`bash``
pnpm test
See LICENSE file in the repository root.