@hammr/do-orm 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,560 @@
1
+ # DO-ORM
2
+
3
+ **Type-safe ORM for Cloudflare Durable Objects with zero runtime overhead**
4
+
5
+ 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.
6
+
7
+ ## Features
8
+
9
+ ✅ **Type-safe schema definitions** - Full TypeScript inference for all CRUD operations
10
+ ✅ **Automatic validation** - Schema validation on every write operation
11
+ ✅ **Efficient indexing** - Single-field indexes for O(log n) queries instead of O(n) scans
12
+ ✅ **Fluent query builder** - Chain `.where()`, `.after()`, `.before()`, `.limit()`, `.orderBy()`
13
+ ✅ **Full CRUD support** - `create()`, `find()`, `update()`, `delete()`, and bulk operations
14
+ ✅ **Zero dependencies** - Pure TypeScript using DO storage primitives
15
+ ✅ **Zero runtime overhead** - Direct wrapper around Durable Objects storage API
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @hammr/do-orm
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ### 1. Define your model
26
+
27
+ ```typescript
28
+ import { DOModel, SchemaDefinition, InferSchemaType } from '@hammr/do-orm';
29
+
30
+ // Define schema with type annotations
31
+ interface EventSchema extends SchemaDefinition {
32
+ id: 'string';
33
+ workspaceId: 'string';
34
+ timestamp: 'date';
35
+ type: 'string';
36
+ data: 'object';
37
+ }
38
+
39
+ // Create model class
40
+ class Event extends DOModel<EventSchema> {
41
+ protected schema: EventSchema = {
42
+ id: 'string',
43
+ workspaceId: 'string',
44
+ timestamp: 'date',
45
+ type: 'string',
46
+ data: 'object',
47
+ };
48
+
49
+ // Define indexes for efficient queries
50
+ protected indexes = ['workspaceId', 'timestamp'] as const;
51
+ }
52
+ ```
53
+
54
+ ### 2. Use in your Durable Object
55
+
56
+ ```typescript
57
+ export class MyDurableObject {
58
+ private eventModel: Event;
59
+
60
+ constructor(state: DurableObjectState) {
61
+ this.eventModel = new Event(state.storage);
62
+ }
63
+
64
+ async fetch(request: Request): Promise<Response> {
65
+ // Create an event
66
+ const event = await this.eventModel.create({
67
+ id: 'evt_123',
68
+ workspaceId: 'ws_abc',
69
+ timestamp: new Date(),
70
+ type: 'click',
71
+ data: { button: 'submit' }
72
+ });
73
+
74
+ // Query events
75
+ const recentEvents = await this.eventModel
76
+ .where({ workspaceId: 'ws_abc' })
77
+ .after(new Date('2024-01-01'))
78
+ .limit(100)
79
+ .orderBy('timestamp', 'desc')
80
+ .execute();
81
+
82
+ return new Response(JSON.stringify(recentEvents));
83
+ }
84
+ }
85
+ ```
86
+
87
+ ## API Reference
88
+
89
+ ### Schema Types
90
+
91
+ DO-ORM supports the following field types:
92
+
93
+ - `'string'` - String values
94
+ - `'number'` - Numeric values (integers and floats)
95
+ - `'boolean'` - Boolean values (true/false)
96
+ - `'date'` - Date objects (automatically serialized/deserialized)
97
+ - `'object'` - Plain JavaScript objects
98
+ - `'array'` - Arrays of any type
99
+
100
+ ### CRUD Operations
101
+
102
+ #### `create(data: T): Promise<T>`
103
+
104
+ Create a new record. Throws if validation fails or ID already exists.
105
+
106
+ ```typescript
107
+ const event = await eventModel.create({
108
+ id: 'evt_1',
109
+ workspaceId: 'ws_abc',
110
+ timestamp: new Date(),
111
+ type: 'pageview',
112
+ data: { page: '/home' }
113
+ });
114
+ ```
115
+
116
+ #### `find(id: string): Promise<T | null>`
117
+
118
+ Find a record by ID. Returns `null` if not found.
119
+
120
+ ```typescript
121
+ const event = await eventModel.find('evt_1');
122
+ if (event) {
123
+ console.log(event.type); // Type-safe access
124
+ }
125
+ ```
126
+
127
+ #### `update(id: string, updates: Partial<T>): Promise<T>`
128
+
129
+ Update a record with partial data. Validates the complete merged record.
130
+
131
+ ```typescript
132
+ const updated = await eventModel.update('evt_1', {
133
+ data: { page: '/about' }
134
+ });
135
+ ```
136
+
137
+ #### `delete(id: string): Promise<boolean>`
138
+
139
+ Delete a record by ID. Returns `true` if deleted, `false` if not found.
140
+
141
+ ```typescript
142
+ const deleted = await eventModel.delete('evt_1');
143
+ ```
144
+
145
+ #### `all(): Promise<T[]>`
146
+
147
+ Get all records (unfiltered).
148
+
149
+ ```typescript
150
+ const allEvents = await eventModel.all();
151
+ ```
152
+
153
+ #### `count(): Promise<number>`
154
+
155
+ Count all records.
156
+
157
+ ```typescript
158
+ const totalEvents = await eventModel.count();
159
+ ```
160
+
161
+ ### Query Builder
162
+
163
+ Chain query methods for powerful filtering and sorting:
164
+
165
+ #### `where(conditions: Partial<T>): QueryBuilder<T>`
166
+
167
+ Filter by field values. Uses indexes when available.
168
+
169
+ ```typescript
170
+ const events = await eventModel
171
+ .where({ workspaceId: 'ws_abc' })
172
+ .execute();
173
+ ```
174
+
175
+ #### `after(date: Date): QueryBuilder<T>`
176
+
177
+ Filter records with date fields after the specified date.
178
+
179
+ ```typescript
180
+ const recentEvents = await eventModel
181
+ .after(new Date('2024-01-01'))
182
+ .execute();
183
+ ```
184
+
185
+ #### `before(date: Date): QueryBuilder<T>`
186
+
187
+ Filter records with date fields before the specified date.
188
+
189
+ ```typescript
190
+ const oldEvents = await eventModel
191
+ .before(new Date('2023-12-31'))
192
+ .execute();
193
+ ```
194
+
195
+ #### `limit(count: number): QueryBuilder<T>`
196
+
197
+ Limit the number of results returned.
198
+
199
+ ```typescript
200
+ const topEvents = await eventModel
201
+ .where({ workspaceId: 'ws_abc' })
202
+ .limit(10)
203
+ .execute();
204
+ ```
205
+
206
+ #### `orderBy(field: keyof T, direction: 'asc' | 'desc'): QueryBuilder<T>`
207
+
208
+ Sort results by a field.
209
+
210
+ ```typescript
211
+ const sortedEvents = await eventModel
212
+ .where({ workspaceId: 'ws_abc' })
213
+ .orderBy('timestamp', 'desc')
214
+ .execute();
215
+ ```
216
+
217
+ #### `execute(): Promise<T[]>`
218
+
219
+ Execute the query and return results.
220
+
221
+ ```typescript
222
+ const events = await eventModel
223
+ .where({ workspaceId: 'ws_abc' })
224
+ .limit(100)
225
+ .execute();
226
+ ```
227
+
228
+ ### Query Chaining Example
229
+
230
+ ```typescript
231
+ const events = await eventModel
232
+ .where({ workspaceId: 'ws_abc' })
233
+ .after(new Date('2024-01-01'))
234
+ .before(new Date('2024-12-31'))
235
+ .orderBy('timestamp', 'desc')
236
+ .limit(50)
237
+ .execute();
238
+ ```
239
+
240
+ ## Indexing
241
+
242
+ Indexes dramatically improve query performance by avoiding full table scans:
243
+
244
+ - **Without index**: O(n) - scans every record
245
+ - **With index**: O(log n) - uses sorted index lookup
246
+
247
+ ### How to define indexes
248
+
249
+ ```typescript
250
+ class Event extends DOModel<EventSchema> {
251
+ protected schema: EventSchema = {
252
+ id: 'string',
253
+ workspaceId: 'string',
254
+ timestamp: 'date',
255
+ type: 'string',
256
+ };
257
+
258
+ // Index these fields for efficient queries
259
+ protected indexes = ['workspaceId', 'timestamp'] as const;
260
+ }
261
+ ```
262
+
263
+ ### When queries use indexes
264
+
265
+ - `.where({ indexedField: value })` - Uses index if first field is indexed
266
+ - Without indexed where clause - Falls back to full scan
267
+
268
+ ### Index maintenance
269
+
270
+ Indexes are automatically maintained:
271
+ - Created during `create()`
272
+ - Updated during `update()` (if indexed fields change)
273
+ - Removed during `delete()`
274
+
275
+ ## Schema Validation
276
+
277
+ DO-ORM validates all data against your schema:
278
+
279
+ ```typescript
280
+ // ✅ Valid - passes validation
281
+ await eventModel.create({
282
+ id: 'evt_1',
283
+ workspaceId: 'ws_abc',
284
+ timestamp: new Date(),
285
+ type: 'click',
286
+ data: {}
287
+ });
288
+
289
+ // ❌ Invalid - throws error
290
+ await eventModel.create({
291
+ id: 'evt_1',
292
+ workspaceId: 123, // Error: must be string
293
+ timestamp: new Date(),
294
+ type: 'click',
295
+ data: {}
296
+ });
297
+
298
+ // ❌ Invalid - throws error
299
+ await eventModel.create({
300
+ id: 'evt_1',
301
+ // Missing required fields
302
+ });
303
+ ```
304
+
305
+ ### Validation errors
306
+
307
+ ```typescript
308
+ try {
309
+ await eventModel.create(invalidData);
310
+ } catch (error) {
311
+ // "Field 'workspaceId' must be a string, got number"
312
+ // "Missing required field: timestamp"
313
+ }
314
+ ```
315
+
316
+ ## TypeScript Inference
317
+
318
+ DO-ORM provides full type inference:
319
+
320
+ ```typescript
321
+ // Define schema
322
+ interface EventSchema extends SchemaDefinition {
323
+ id: 'string';
324
+ workspaceId: 'string';
325
+ timestamp: 'date';
326
+ }
327
+
328
+ class Event extends DOModel<EventSchema> {
329
+ protected schema: EventSchema = {
330
+ id: 'string',
331
+ workspaceId: 'string',
332
+ timestamp: 'date',
333
+ };
334
+ protected indexes = ['workspaceId'] as const;
335
+ }
336
+
337
+ // TypeScript knows the exact type!
338
+ const event = await eventModel.find('evt_1');
339
+ // ^? Event | null
340
+
341
+ if (event) {
342
+ event.id; // string
343
+ event.workspaceId; // string
344
+ event.timestamp; // Date
345
+ event.unknown; // ❌ TypeScript error
346
+ }
347
+ ```
348
+
349
+ ## Advanced Usage
350
+
351
+ ### Custom table names
352
+
353
+ ```typescript
354
+ class Event extends DOModel<EventSchema> {
355
+ constructor(storage: DurableObjectStorage) {
356
+ super(storage, 'custom_events_table');
357
+ }
358
+
359
+ protected schema: EventSchema = { /* ... */ };
360
+ protected indexes = [] as const;
361
+ }
362
+ ```
363
+
364
+ ### Multiple models in one DO
365
+
366
+ ```typescript
367
+ export class MyDurableObject {
368
+ private events: Event;
369
+ private users: User;
370
+
371
+ constructor(state: DurableObjectState) {
372
+ this.events = new Event(state.storage);
373
+ this.users = new User(state.storage, 'users_table');
374
+ }
375
+
376
+ async fetch(request: Request): Promise<Response> {
377
+ const event = await this.events.find('evt_1');
378
+ const user = await this.users.find('user_1');
379
+ // ...
380
+ }
381
+ }
382
+ ```
383
+
384
+ ## Performance Considerations
385
+
386
+ ### Index usage
387
+
388
+ - **Indexed queries**: Fast O(log n) lookups
389
+ - **Non-indexed queries**: Slower O(n) full scans
390
+ - **Best practice**: Index frequently queried fields
391
+
392
+ ### Storage efficiency
393
+
394
+ - Records stored as: `{tableName}:{id}`
395
+ - Indexes stored as: `index:{tableName}:{field}:{value}`
396
+ - Dates serialized as ISO strings for efficient sorting
397
+
398
+ ### Query optimization tips
399
+
400
+ 1. **Use indexes** - Define indexes for frequently queried fields
401
+ 2. **Limit results** - Always use `.limit()` for large datasets
402
+ 3. **Specific where clauses** - Filter by indexed fields first
403
+ 4. **Batch operations** - Consider batching writes for bulk inserts
404
+
405
+ ## Limitations
406
+
407
+ - **No compound indexes** - Only single-field indexes (for now)
408
+ - **No transactions** - Each operation is atomic but not grouped
409
+ - **No joins** - Each model is independent
410
+ - **No migrations** - Schema changes require manual data migration
411
+
412
+ ## Examples
413
+
414
+ ### Analytics events tracker
415
+
416
+ ```typescript
417
+ interface AnalyticsSchema extends SchemaDefinition {
418
+ id: 'string';
419
+ sessionId: 'string';
420
+ userId: 'string';
421
+ event: 'string';
422
+ timestamp: 'date';
423
+ properties: 'object';
424
+ }
425
+
426
+ class Analytics extends DOModel<AnalyticsSchema> {
427
+ protected schema: AnalyticsSchema = {
428
+ id: 'string',
429
+ sessionId: 'string',
430
+ userId: 'string',
431
+ event: 'string',
432
+ timestamp: 'date',
433
+ properties: 'object',
434
+ };
435
+
436
+ protected indexes = ['userId', 'sessionId', 'timestamp'] as const;
437
+ }
438
+
439
+ // Track an event
440
+ await analytics.create({
441
+ id: generateId(),
442
+ sessionId: 'session_abc',
443
+ userId: 'user_123',
444
+ event: 'purchase',
445
+ timestamp: new Date(),
446
+ properties: { amount: 99.99, currency: 'USD' }
447
+ });
448
+
449
+ // Get user's recent events
450
+ const userEvents = await analytics
451
+ .where({ userId: 'user_123' })
452
+ .after(thirtyDaysAgo)
453
+ .orderBy('timestamp', 'desc')
454
+ .limit(100)
455
+ .execute();
456
+ ```
457
+
458
+ ### Task queue
459
+
460
+ ```typescript
461
+ interface TaskSchema extends SchemaDefinition {
462
+ id: 'string';
463
+ status: 'string';
464
+ priority: 'number';
465
+ createdAt: 'date';
466
+ payload: 'object';
467
+ }
468
+
469
+ class Task extends DOModel<TaskSchema> {
470
+ protected schema: TaskSchema = {
471
+ id: 'string',
472
+ status: 'string',
473
+ priority: 'number',
474
+ createdAt: 'date',
475
+ payload: 'object',
476
+ };
477
+
478
+ protected indexes = ['status', 'priority'] as const;
479
+ }
480
+
481
+ // Add task
482
+ await task.create({
483
+ id: 'task_1',
484
+ status: 'pending',
485
+ priority: 1,
486
+ createdAt: new Date(),
487
+ payload: { action: 'send_email' }
488
+ });
489
+
490
+ // Get pending tasks
491
+ const pending = await task
492
+ .where({ status: 'pending' })
493
+ .orderBy('priority', 'asc')
494
+ .limit(10)
495
+ .execute();
496
+
497
+ // Process and mark complete
498
+ for (const t of pending) {
499
+ await processTask(t);
500
+ await task.update(t.id, { status: 'completed' });
501
+ }
502
+ ```
503
+
504
+ ## Testing
505
+
506
+ ### Unit Tests
507
+
508
+ Run the unit test suite:
509
+
510
+ ```bash
511
+ npm test
512
+ ```
513
+
514
+ Tests include:
515
+ - Schema validation (type checking, required fields)
516
+ - CRUD operations (create, read, update, delete)
517
+ - Query builder (where, limit, orderBy, date ranges)
518
+ - Index usage and maintenance
519
+ - Edge cases (duplicates, missing records)
520
+
521
+ ### Integration Tests (with Cloudflare Workers)
522
+
523
+ Test the ORM in a real Cloudflare Workers environment:
524
+
525
+ ```bash
526
+ # Terminal 1: Start the worker
527
+ npm run dev
528
+
529
+ # Terminal 2: Run integration tests
530
+ npm run test:worker
531
+ ```
532
+
533
+ The integration tests verify the complete stack:
534
+ - Worker HTTP endpoints
535
+ - Durable Object instantiation
536
+ - DO-ORM with real DO storage
537
+ - Schema validation in production
538
+ - Query performance with indexes
539
+
540
+ See [TESTING.md](./TESTING.md) for more details on testing with Cloudflare Workers.
541
+
542
+ ## Contributing
543
+
544
+ This is v1 - there's lots of room for improvement!
545
+
546
+ **Potential enhancements:**
547
+ - Compound indexes (multiple fields)
548
+ - Transactions support
549
+ - Query result streaming
550
+ - Migration helpers
551
+ - Soft deletes
552
+ - Hooks (beforeCreate, afterUpdate, etc.)
553
+
554
+ ## License
555
+
556
+ MIT
557
+
558
+ ---
559
+
560
+ Built for the Cloudflare Workers ecosystem. Works seamlessly with Durable Objects and provides a better developer experience than raw storage API calls.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * DO-ORM v1 - Type-safe ORM for Cloudflare Durable Objects
3
+ * Zero dependencies, pure TypeScript implementation
4
+ */
5
+ import type { SchemaDefinition, QueryOptions, InferSchemaType } from './types';
6
+ export * from './types';
7
+ /**
8
+ * Base class for all DO models
9
+ * Provides CRUD operations, schema validation, and indexing
10
+ */
11
+ export declare abstract class DOModel<S extends SchemaDefinition> {
12
+ protected abstract schema: S;
13
+ protected abstract indexes: (keyof InferSchemaType<S>)[];
14
+ protected storage: DurableObjectStorage;
15
+ protected tableName: string;
16
+ constructor(storage: DurableObjectStorage, tableName?: string);
17
+ /**
18
+ * Validate a value against a schema field type
19
+ */
20
+ private validateField;
21
+ /**
22
+ * Validate an entire record against the schema
23
+ */
24
+ private validateSchema;
25
+ /**
26
+ * Generate storage key for a record
27
+ */
28
+ private getRecordKey;
29
+ /**
30
+ * Generate storage key for an index
31
+ */
32
+ private getIndexKey;
33
+ /**
34
+ * Get all index entry keys for a given field
35
+ */
36
+ private getIndexPrefix;
37
+ /**
38
+ * Serialize a value for storage (convert Dates to ISO strings)
39
+ */
40
+ private serialize;
41
+ /**
42
+ * Deserialize a value from storage (convert ISO strings back to Dates)
43
+ */
44
+ private deserialize;
45
+ /**
46
+ * Update indexes for a record
47
+ */
48
+ private updateIndexes;
49
+ /**
50
+ * Remove record ID from indexes
51
+ */
52
+ private removeFromIndexes;
53
+ /**
54
+ * Create a new record
55
+ */
56
+ create(data: InferSchemaType<S>): Promise<InferSchemaType<S>>;
57
+ /**
58
+ * Find a record by ID
59
+ */
60
+ find(id: string): Promise<InferSchemaType<S> | null>;
61
+ /**
62
+ * Update a record
63
+ */
64
+ update(id: string, updates: Partial<InferSchemaType<S>>): Promise<InferSchemaType<S>>;
65
+ /**
66
+ * Delete a record
67
+ */
68
+ delete(id: string): Promise<boolean>;
69
+ /**
70
+ * Query builder - returns all matching records
71
+ */
72
+ query(options?: QueryOptions<InferSchemaType<S>>): Promise<InferSchemaType<S>[]>;
73
+ /**
74
+ * Query builder with fluent API
75
+ */
76
+ where(conditions: Partial<InferSchemaType<S>>): QueryBuilder<S>;
77
+ /**
78
+ * Get all records
79
+ */
80
+ all(): Promise<InferSchemaType<S>[]>;
81
+ /**
82
+ * Count all records
83
+ */
84
+ count(): Promise<number>;
85
+ }
86
+ /**
87
+ * Fluent query builder
88
+ */
89
+ export declare class QueryBuilder<S extends SchemaDefinition> {
90
+ private model;
91
+ private options;
92
+ constructor(model: DOModel<S>, options?: QueryOptions<InferSchemaType<S>>);
93
+ where(conditions: Partial<InferSchemaType<S>>): QueryBuilder<S>;
94
+ after(date: Date): QueryBuilder<S>;
95
+ before(date: Date): QueryBuilder<S>;
96
+ limit(count: number): QueryBuilder<S>;
97
+ orderBy(field: keyof InferSchemaType<S>, direction?: 'asc' | 'desc'): QueryBuilder<S>;
98
+ execute(): Promise<InferSchemaType<S>[]>;
99
+ }
package/dist/index.js ADDED
@@ -0,0 +1,358 @@
1
+ /**
2
+ * DO-ORM v1 - Type-safe ORM for Cloudflare Durable Objects
3
+ * Zero dependencies, pure TypeScript implementation
4
+ */
5
+ export * from './types';
6
+ /**
7
+ * Base class for all DO models
8
+ * Provides CRUD operations, schema validation, and indexing
9
+ */
10
+ export class DOModel {
11
+ constructor(storage, tableName) {
12
+ this.storage = storage;
13
+ this.tableName = tableName || this.constructor.name.toLowerCase();
14
+ }
15
+ /**
16
+ * Validate a value against a schema field type
17
+ */
18
+ validateField(value, fieldType, fieldName) {
19
+ switch (fieldType) {
20
+ case 'string':
21
+ if (typeof value !== 'string') {
22
+ throw new Error(`Field '${fieldName}' must be a string, got ${typeof value}`);
23
+ }
24
+ break;
25
+ case 'number':
26
+ if (typeof value !== 'number' || isNaN(value)) {
27
+ throw new Error(`Field '${fieldName}' must be a number, got ${typeof value}`);
28
+ }
29
+ break;
30
+ case 'boolean':
31
+ if (typeof value !== 'boolean') {
32
+ throw new Error(`Field '${fieldName}' must be a boolean, got ${typeof value}`);
33
+ }
34
+ break;
35
+ case 'date':
36
+ if (!(value instanceof Date) || isNaN(value.getTime())) {
37
+ throw new Error(`Field '${fieldName}' must be a valid Date, got ${typeof value}`);
38
+ }
39
+ break;
40
+ case 'object':
41
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
42
+ throw new Error(`Field '${fieldName}' must be an object, got ${typeof value}`);
43
+ }
44
+ break;
45
+ case 'array':
46
+ if (!Array.isArray(value)) {
47
+ throw new Error(`Field '${fieldName}' must be an array, got ${typeof value}`);
48
+ }
49
+ break;
50
+ default:
51
+ throw new Error(`Unknown field type: ${fieldType}`);
52
+ }
53
+ }
54
+ /**
55
+ * Validate an entire record against the schema
56
+ */
57
+ validateSchema(data) {
58
+ // Check for missing required fields
59
+ for (const [fieldName, fieldType] of Object.entries(this.schema)) {
60
+ if (!(fieldName in data)) {
61
+ throw new Error(`Missing required field: ${fieldName}`);
62
+ }
63
+ this.validateField(data[fieldName], fieldType, fieldName);
64
+ }
65
+ }
66
+ /**
67
+ * Generate storage key for a record
68
+ */
69
+ getRecordKey(id) {
70
+ return `${this.tableName}:${id}`;
71
+ }
72
+ /**
73
+ * Generate storage key for an index
74
+ */
75
+ getIndexKey(field, value) {
76
+ const normalizedValue = value instanceof Date ? value.toISOString() : String(value);
77
+ return `index:${this.tableName}:${field}:${normalizedValue}`;
78
+ }
79
+ /**
80
+ * Get all index entry keys for a given field
81
+ */
82
+ getIndexPrefix(field) {
83
+ return `index:${this.tableName}:${field}:`;
84
+ }
85
+ /**
86
+ * Serialize a value for storage (convert Dates to ISO strings)
87
+ */
88
+ serialize(data) {
89
+ const serialized = {};
90
+ for (const [key, value] of Object.entries(data)) {
91
+ if (value instanceof Date) {
92
+ serialized[key] = value.toISOString();
93
+ }
94
+ else {
95
+ serialized[key] = value;
96
+ }
97
+ }
98
+ return serialized;
99
+ }
100
+ /**
101
+ * Deserialize a value from storage (convert ISO strings back to Dates)
102
+ */
103
+ deserialize(data) {
104
+ const deserialized = {};
105
+ for (const [key, value] of Object.entries(data)) {
106
+ const fieldType = this.schema[key];
107
+ if (fieldType === 'date' && typeof value === 'string') {
108
+ deserialized[key] = new Date(value);
109
+ }
110
+ else {
111
+ deserialized[key] = value;
112
+ }
113
+ }
114
+ return deserialized;
115
+ }
116
+ /**
117
+ * Update indexes for a record
118
+ */
119
+ async updateIndexes(id, data) {
120
+ for (const indexField of this.indexes) {
121
+ const fieldValue = data[indexField];
122
+ const indexKey = this.getIndexKey(String(indexField), fieldValue);
123
+ // Get existing IDs in this index
124
+ const existingIds = await this.storage.get(indexKey) || [];
125
+ // Add ID if not already present
126
+ if (!existingIds.includes(id)) {
127
+ existingIds.push(id);
128
+ await this.storage.put(indexKey, existingIds);
129
+ }
130
+ }
131
+ }
132
+ /**
133
+ * Remove record ID from indexes
134
+ */
135
+ async removeFromIndexes(id, data) {
136
+ for (const indexField of this.indexes) {
137
+ const fieldValue = data[indexField];
138
+ const indexKey = this.getIndexKey(String(indexField), fieldValue);
139
+ // Get existing IDs and remove this one
140
+ const existingIds = await this.storage.get(indexKey) || [];
141
+ const filteredIds = existingIds.filter(existingId => existingId !== id);
142
+ if (filteredIds.length > 0) {
143
+ await this.storage.put(indexKey, filteredIds);
144
+ }
145
+ else {
146
+ await this.storage.delete(indexKey);
147
+ }
148
+ }
149
+ }
150
+ /**
151
+ * Create a new record
152
+ */
153
+ async create(data) {
154
+ // Validate schema
155
+ this.validateSchema(data);
156
+ // Check if record already exists
157
+ const id = data.id;
158
+ if (!id) {
159
+ throw new Error('Record must have an id field');
160
+ }
161
+ const key = this.getRecordKey(id);
162
+ const existing = await this.storage.get(key);
163
+ if (existing) {
164
+ throw new Error(`Record with id '${id}' already exists`);
165
+ }
166
+ // Serialize and store
167
+ const serialized = this.serialize(data);
168
+ await this.storage.put(key, serialized);
169
+ // Update indexes
170
+ await this.updateIndexes(id, data);
171
+ return data;
172
+ }
173
+ /**
174
+ * Find a record by ID
175
+ */
176
+ async find(id) {
177
+ const key = this.getRecordKey(id);
178
+ const data = await this.storage.get(key);
179
+ if (!data) {
180
+ return null;
181
+ }
182
+ return this.deserialize(data);
183
+ }
184
+ /**
185
+ * Update a record
186
+ */
187
+ async update(id, updates) {
188
+ const existing = await this.find(id);
189
+ if (!existing) {
190
+ throw new Error(`Record with id '${id}' not found`);
191
+ }
192
+ // Merge updates
193
+ const updated = { ...existing, ...updates };
194
+ // Validate the complete record
195
+ this.validateSchema(updated);
196
+ // Remove old indexes if indexed fields changed
197
+ const indexedFieldsChanged = this.indexes.some(field => field in updates && updates[field] !== existing[field]);
198
+ if (indexedFieldsChanged) {
199
+ await this.removeFromIndexes(id, existing);
200
+ }
201
+ // Store updated record
202
+ const serialized = this.serialize(updated);
203
+ await this.storage.put(this.getRecordKey(id), serialized);
204
+ // Update indexes with new values
205
+ if (indexedFieldsChanged) {
206
+ await this.updateIndexes(id, updated);
207
+ }
208
+ return updated;
209
+ }
210
+ /**
211
+ * Delete a record
212
+ */
213
+ async delete(id) {
214
+ const existing = await this.find(id);
215
+ if (!existing) {
216
+ return false;
217
+ }
218
+ // Remove from indexes
219
+ await this.removeFromIndexes(id, existing);
220
+ // Delete record
221
+ await this.storage.delete(this.getRecordKey(id));
222
+ return true;
223
+ }
224
+ /**
225
+ * Query builder - returns all matching records
226
+ */
227
+ async query(options = {}) {
228
+ let candidateIds = [];
229
+ // Use indexes if where clause matches an indexed field
230
+ if (options.where) {
231
+ const whereEntries = Object.entries(options.where);
232
+ if (whereEntries.length > 0) {
233
+ const [field, value] = whereEntries[0];
234
+ // Check if this field is indexed
235
+ if (this.indexes.includes(field)) {
236
+ const indexKey = this.getIndexKey(field, value);
237
+ candidateIds = await this.storage.get(indexKey) || [];
238
+ }
239
+ }
240
+ }
241
+ // If no index was used, scan all records (slower)
242
+ if (candidateIds.length === 0 && !options.where) {
243
+ const prefix = `${this.tableName}:`;
244
+ const allKeys = await this.storage.list({ prefix });
245
+ candidateIds = Array.from(allKeys.keys()).map(key => key.toString().replace(prefix, ''));
246
+ }
247
+ // Load all candidate records
248
+ const records = [];
249
+ for (const id of candidateIds) {
250
+ const record = await this.find(id);
251
+ if (record) {
252
+ records.push(record);
253
+ }
254
+ }
255
+ // Apply filters
256
+ let filtered = records;
257
+ // Filter by where clause (additional fields not covered by index)
258
+ if (options.where) {
259
+ filtered = filtered.filter(record => {
260
+ for (const [key, value] of Object.entries(options.where)) {
261
+ if (record[key] !== value) {
262
+ return false;
263
+ }
264
+ }
265
+ return true;
266
+ });
267
+ }
268
+ // Filter by date range (after/before)
269
+ if (options.after || options.before) {
270
+ filtered = filtered.filter(record => {
271
+ // Find date field in schema
272
+ for (const [field, type] of Object.entries(this.schema)) {
273
+ if (type === 'date') {
274
+ const dateValue = record[field];
275
+ if (dateValue instanceof Date) {
276
+ if (options.after && dateValue <= options.after)
277
+ return false;
278
+ if (options.before && dateValue >= options.before)
279
+ return false;
280
+ }
281
+ }
282
+ }
283
+ return true;
284
+ });
285
+ }
286
+ // Sort
287
+ if (options.orderBy) {
288
+ const { field, direction } = options.orderBy;
289
+ filtered.sort((a, b) => {
290
+ const aVal = a[field];
291
+ const bVal = b[field];
292
+ let comparison = 0;
293
+ if (aVal < bVal)
294
+ comparison = -1;
295
+ if (aVal > bVal)
296
+ comparison = 1;
297
+ return direction === 'desc' ? -comparison : comparison;
298
+ });
299
+ }
300
+ // Apply limit
301
+ if (options.limit) {
302
+ filtered = filtered.slice(0, options.limit);
303
+ }
304
+ return filtered;
305
+ }
306
+ /**
307
+ * Query builder with fluent API
308
+ */
309
+ where(conditions) {
310
+ return new QueryBuilder(this, { where: conditions });
311
+ }
312
+ /**
313
+ * Get all records
314
+ */
315
+ async all() {
316
+ return this.query();
317
+ }
318
+ /**
319
+ * Count all records
320
+ */
321
+ async count() {
322
+ const prefix = `${this.tableName}:`;
323
+ const allKeys = await this.storage.list({ prefix });
324
+ return allKeys.size;
325
+ }
326
+ }
327
+ /**
328
+ * Fluent query builder
329
+ */
330
+ export class QueryBuilder {
331
+ constructor(model, options = {}) {
332
+ this.model = model;
333
+ this.options = options;
334
+ }
335
+ where(conditions) {
336
+ this.options.where = { ...this.options.where, ...conditions };
337
+ return this;
338
+ }
339
+ after(date) {
340
+ this.options.after = date;
341
+ return this;
342
+ }
343
+ before(date) {
344
+ this.options.before = date;
345
+ return this;
346
+ }
347
+ limit(count) {
348
+ this.options.limit = count;
349
+ return this;
350
+ }
351
+ orderBy(field, direction = 'asc') {
352
+ this.options.orderBy = { field, direction };
353
+ return this;
354
+ }
355
+ async execute() {
356
+ return this.model.query(this.options);
357
+ }
358
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Type definitions for DO-ORM
3
+ */
4
+ export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'array';
5
+ export interface SchemaDefinition {
6
+ [key: string]: FieldType;
7
+ }
8
+ export interface QueryOptions<T> {
9
+ where?: Partial<T>;
10
+ after?: Date;
11
+ before?: Date;
12
+ limit?: number;
13
+ orderBy?: {
14
+ field: keyof T;
15
+ direction: 'asc' | 'desc';
16
+ };
17
+ }
18
+ export interface ModelConfig {
19
+ tableName?: string;
20
+ }
21
+ export type InferSchemaType<S extends SchemaDefinition> = {
22
+ [K in keyof S]: S[K] extends 'string' ? string : S[K] extends 'number' ? number : S[K] extends 'boolean' ? boolean : S[K] extends 'date' ? Date : S[K] extends 'object' ? Record<string, any> : S[K] extends 'array' ? any[] : never;
23
+ };
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Type definitions for DO-ORM
3
+ */
4
+ export {};
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@hammr/do-orm",
3
+ "version": "1.0.0",
4
+ "description": "Type-safe ORM for Cloudflare Durable Objects with zero runtime overhead",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "test": "tsx test.ts",
10
+ "dev": "wrangler dev",
11
+ "deploy": "wrangler deploy",
12
+ "test:worker": "tsx test-worker.ts",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "keywords": [
16
+ "cloudflare",
17
+ "durable-objects",
18
+ "orm",
19
+ "typescript",
20
+ "database",
21
+ "stateless"
22
+ ],
23
+ "author": "Edge Foundry, Inc.",
24
+ "license": "Apache-2.0",
25
+ "devDependencies": {
26
+ "@cloudflare/workers-types": "^4.20231218.0",
27
+ "typescript": "^5.3.3",
28
+ "tsx": "^4.7.0",
29
+ "wrangler": "^3.78.0"
30
+ },
31
+ "peerDependencies": {
32
+ "@cloudflare/workers-types": ">=4.0.0"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "README.md"
37
+ ]
38
+ }