miragejs-orm 0.1.0 → 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 CHANGED
@@ -1,17 +1,1788 @@
1
- # @miragejs/orm
1
+ <div align="center">
2
2
 
3
- > Standalone ORM and in-memory database for JavaScript apps — extracted from MirageJS
3
+ <img src="./docs/logo.svg" alt="MirageJS Logo" width="100" />
4
+
5
+ # MirageJS ORM
6
+
7
+ > A TypeScript-first ORM for building in-memory databases with models, relationships, and factories
8
+
9
+ [![npm version](https://img.shields.io/npm/v/miragejs-orm)](https://www.npmjs.com/package/miragejs-orm)
10
+ [![Bundle Size](https://img.shields.io/bundlephobia/minzip/miragejs-orm)](https://bundlephobia.com/package/miragejs-orm)
11
+ [![License](https://img.shields.io/npm/l/miragejs-orm)](./LICENSE)
12
+ [![Coverage](https://img.shields.io/badge/coverage-96%25-brightgreen)]()
13
+ [![TypeScript](https://img.shields.io/badge/TypeScript-100%25-blue)](https://www.typescriptlang.org/)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## ✨ What is miragejs-orm?
20
+
21
+ **miragejs-orm** is a complete TypeScript rewrite of the powerful ORM layer from MirageJS, designed to give frontend developers the freedom to quickly create type-safe mocks for both testing and development — without backend dependencies.
22
+
23
+ Build realistic, relational data models in memory with factories, traits, relationships, and serialization, all with **100% type safety** and a modern, fluent API.
24
+
25
+ ---
26
+
27
+ ## 🚀 Why Choose miragejs-orm?
28
+
29
+ ### Compared to MirageJS
30
+
31
+ While MirageJS is an excellent solution for full API mocking, `miragejs-orm` takes the most powerful part - the ORM — and enhances it:
32
+
33
+ - **🎯 Fully Rewritten in TypeScript** - Built from the ground up with TypeScript, providing complete type safety and excellent IDE autocomplete
34
+ - **🪶 Zero Dependencies** - No external dependencies means smaller bundle size (~55KB) and no supply chain concerns
35
+ - **🔌 Framework Agnostic** - Use with any HTTP interceptor library (MSW, Mirage Server, Axios interceptors, etc.) or testing framework
36
+ - **⚡ Modern Fluent API** - Declarative builder patterns let you construct schemas, models, and factories with an intuitive, chainable API
37
+ - **📦 No Inflection Magic Under The Hood** - Now you control exactly how your model names and attributes are formatted - what you define is what you get
38
+ - **✅ Battle Tested** - 900+ test cases with 95% code coverage, including type tests, ensure reliability
39
+ - **🔧 Modern Tooling** - Built with modern build tools and package standards for optimal developer experience
40
+
41
+ ### Key Benefits
42
+
43
+ - **Develop UI-First** - Don't wait for backend APIs. Build complete frontend features with realistic data
44
+ - **Flexible Data Modeling** - Create models that mirror your backend entities OR design custom models for specific endpoints
45
+ - **Built-in Serialization** - Transform your data on output with serializers to match API formats, hide sensitive fields, and control response structure
46
+ - **Type-Safe Mocking** - Full TypeScript support means your mocks stay in sync with your types
47
+ - **Testing & Development** - Perfect for unit tests, integration tests, Storybook stories, and development environments
48
+
49
+ **📂 Example project:** See the [task-board example](./examples/task-board) for a full reference: schema setup, models, collections, relations, factories, seeds, serializers, and MSW handlers. Use it to learn the library and as a pattern for your own projects.
4
50
 
5
51
  ---
6
52
 
7
- ## Overview
53
+ ## 💭 Philosophy
54
+
55
+ ### Freedom Over Rigidity
56
+
57
+ The core idea behind `miragejs-orm` is to give frontend developers a **playground, not a prison**. We don't force you to perfectly replicate your backend architecture - instead, we give you the tools to create exactly what you need:
8
58
 
9
- `@miragejs/orm` provides MirageJS’s powerful in-memory data modeling and relational layer as a **standalone library**, independent of request handling or HTTP mocking.
59
+ - **Model Your API, Your Way** Build a complete relational model that mirrors your server, OR create minimal models for specific endpoint outputs
60
+ - **No Scope Creep** – Keep your mock data within the library's scope rather than managing complex state in route handlers or test setup
61
+ - **UI-First Development** – Get ahead of backend development and prototype features with realistic, relational data
10
62
 
11
- It enables:
12
- - Declaring models and relationships
13
- - Creating, querying, and mutating records in memory
14
- - Building factories and managing lifecycle hooks
15
- - Simulating a normalized relational data store in tests
63
+ ### Schema-less but Type-Safe
64
+
65
+ We embrace a unique philosophy:
66
+
67
+ - **No Runtime Validation** Models are schema-less by design. You're responsible for keeping your test data correct so tests meet expectations
68
+ - **100% Type Safety** – On our side, we provide complete TypeScript support to make mock management fully type-safe
69
+ - **Developer Freedom** – We give you powerful tools without imposing backend-style validation constraints
70
+
71
+ This approach means faster iteration, simpler setup, and complete control over your mock data while maintaining the benefits of TypeScript's compile-time safety.
16
72
 
17
73
  ---
74
+
75
+ <div align="center">
76
+
77
+ # 📖 Quick Guide
78
+
79
+ </div>
80
+
81
+ ## 📦 Installation
82
+
83
+ ```bash
84
+ npm install miragejs-orm
85
+
86
+ # or
87
+ yarn add miragejs-orm
88
+
89
+ # or
90
+ pnpm add miragejs-orm
91
+ ```
92
+
93
+ ---
94
+
95
+ ## 🏃 Quick Start
96
+
97
+ Here's a taste of what you can do with `miragejs-orm`:
98
+
99
+ ```typescript
100
+ import { model, schema, collection, factory, relations } from 'miragejs-orm';
101
+
102
+ // 1. Define your models
103
+ const userModel = model('user', 'users')
104
+ .attrs<{ name: string; email: string }>()
105
+ .build();
106
+
107
+ const postModel = model('post', 'posts')
108
+ .attrs<{ title: string; content: string; authorId: string }>()
109
+ .build();
110
+
111
+ // 2. Create factories with fake data
112
+ const userFactory = factory()
113
+ .model(userModel)
114
+ .attrs({
115
+ name: () => 'John Doe',
116
+ email: () => 'john@example.com',
117
+ })
118
+ .build();
119
+
120
+ // 3. Setup your schema with relationships
121
+ const testSchema = schema()
122
+ .collections({
123
+ users: collection()
124
+ .model(userModel)
125
+ .factory(userFactory)
126
+ .relationships({
127
+ posts: relations.hasMany(postModel),
128
+ })
129
+ .build(),
130
+
131
+ posts: collection()
132
+ .model(postModel)
133
+ .relationships({
134
+ author: relations.belongsTo(userModel, { foreignKey: 'authorId' }),
135
+ })
136
+ .build(),
137
+ })
138
+ .build();
139
+
140
+ // 4. Use it!
141
+ const user = testSchema.users.create({ name: 'Alice' });
142
+
143
+ const post = testSchema.posts.create({
144
+ title: 'Hello World',
145
+ content: 'My first post',
146
+ authorId: user.id,
147
+ });
148
+
149
+ console.log(user.posts.length); // 1
150
+ console.log(post.author.name); // 'Alice'
151
+ ```
152
+
153
+ ---
154
+
155
+ ## 📚 Core Concepts
156
+
157
+ ### 1. Model Templates
158
+
159
+ **Model Templates** define the structure of your data entities. They're created using the `model()` builder and are schema-less at runtime but fully typed at compile time.
160
+
161
+ Model Templates are designed to be **shareable across your schema** - you can reference the same template when setting up relationships and collections, ensuring consistent type inference throughout your application.
162
+
163
+ ```typescript
164
+ import { model } from 'miragejs-orm';
165
+
166
+ // Define your model attributes interface
167
+ interface UserAttrs {
168
+ name: string;
169
+ email: string;
170
+ role?: string;
171
+ }
172
+
173
+ // Create a model template
174
+ const userModel = model('user', 'users').attrs<UserAttrs>().build();
175
+
176
+ // This template can now be shared across collections and relationships
177
+ ```
178
+
179
+ <details>
180
+ <summary><strong>Key points</strong></summary>
181
+
182
+ - Model Templates are the building blocks created by the `model()` builder.
183
+ - First argument is the **model name** (singular), second is the **collection name** (plural).
184
+ - Use `.attrs<T>()` to define the TypeScript interface for your model.
185
+ - **JavaScript users** — You can pass a plain object to `.attrs()` instead of a generic (e.g. `.attrs({ name: '', email: '' })`). The object shape gives the IDE good IntelliSense for attributes without TypeScript.
186
+ - Templates are **shareable** — use the same template reference for relationships and type inference.
187
+ - Models are immutable once created.
188
+
189
+ </details>
190
+
191
+ ### 2. Collections
192
+
193
+ Collections are containers for models that live in your schema. They handle CRUD operations and queries.
194
+
195
+ ```typescript
196
+ import { collection, relations } from 'miragejs-orm';
197
+
198
+ const userCollection = collection()
199
+ .model(userModel)
200
+ .factory(userFactory) // Optional
201
+ .relationships({ posts: relations.hasMany(postModel) }) // Optional
202
+ .serializer(userSerializer) // Optional
203
+ .build();
204
+ ```
205
+
206
+ **Creating**
207
+
208
+ ```typescript
209
+ // New (in-memory only, not saved to DB — uses only the attributes you pass, no factory)
210
+ const user = testSchema.users.new({
211
+ name: 'Alice',
212
+ email: 'alice@example.com',
213
+ });
214
+
215
+ // Create with custom attributes
216
+ const user = testSchema.users.create({
217
+ name: 'Alice',
218
+ email: 'alice@example.com',
219
+ });
220
+
221
+ // Create with factory traits
222
+ const adminUser = testSchema.users.create({ name: 'Admin' }, 'admin');
223
+
224
+ // Create multiple identical records
225
+ const users = testSchema.users.createMany(3);
226
+
227
+ // Create multiple different records: two regular users and one admin
228
+ const users = testSchema.users.createMany([
229
+ [{ name: 'Alice', email: 'alice@example.com' }],
230
+ [{ name: 'Bob', email: 'bob@example.com' }],
231
+ ['admin'], // Using a trait
232
+ ]);
233
+
234
+ // Find or create by attributes
235
+ const user = testSchema.users.findOrCreateBy(
236
+ { email: 'alice@example.com' },
237
+ { name: 'Alice', role: 'user' },
238
+ );
239
+
240
+ // Find many or create by attributes
241
+ const users = testSchema.users.findManyOrCreateBy(
242
+ 5,
243
+ { role: 'user' },
244
+ { isActive: true },
245
+ );
246
+ ```
247
+
248
+ **Querying**
249
+
250
+ ```typescript
251
+ // Find by ID
252
+ const user = testSchema.users.find('1');
253
+
254
+ // Find with conditions
255
+ const admin = testSchema.users.find({ where: { role: 'admin' } });
256
+
257
+ // Find many by IDs
258
+ const users = testSchema.users.findMany(['1', '2', '3']);
259
+
260
+ // Find with predicate function
261
+ const activeUsers = testSchema.users.findMany({
262
+ where: (user) => user.isActive && user.role === 'admin',
263
+ });
264
+
265
+ // Get all records
266
+ const allUsers = testSchema.users.all();
267
+
268
+ // Get first / last / by index
269
+ const first = testSchema.users.first();
270
+ const last = testSchema.users.last();
271
+ const thirdUser = testSchema.users.at(2);
272
+ ```
273
+
274
+ **Record management (update & remove)**
275
+
276
+ ```typescript
277
+ // Update a record (model method)
278
+ user.update({ name: 'Bob', role: 'admin' });
279
+
280
+ // Delete a record (model method)
281
+ user.destroy();
282
+
283
+ // Delete by ID (collection method)
284
+ testSchema.users.delete('1');
285
+
286
+ // Delete multiple records (collection method)
287
+ testSchema.users.deleteMany(['1', '2', '3']);
288
+ ```
289
+
290
+ <details>
291
+ <summary><strong>Key points</strong></summary>
292
+
293
+ - **Creating and finding** — Use collection methods: `create()`, `createMany()`, `find()`, `findMany()`, `findOrCreateBy()`, `findManyOrCreateBy()`, `all()`, `first()`, `last()`, `at()`.
294
+ - **Updating and removing** — Use model methods: `model.update()`, `model.destroy()`, or collection helpers `collection.delete(id)` / `collection.deleteMany(ids)`.
295
+ - **`new()`** — Creates a model instance in memory only (not saved to the DB), using only the attributes you pass (no factory). Exists mainly for internal use but is exposed for parity with the legacy MirageJS API. Prefer `create()` for building and persisting models in normal application or test code.
296
+ - **Database (db) API** — Operating on raw records via `schema.db.collectionName` is not recommended for normal use. The db API is exposed mainly for debugging and low-level access.
297
+ - **Naming conventions** — The API uses consistent singular/plural and query patterns:
298
+ - **Singular/plural:** `create()` / `createMany()`, `find()` / `findMany()`, `delete()` / `deleteMany()`.
299
+ - **Query API:** Use `find({ where })` and `findMany({ where })` instead of separate `findBy` / `where`-style APIs.
300
+ - **Find-or-create:** `findOrCreateBy()` and `findManyOrCreateBy()` for conditional creation.
301
+
302
+ </details>
303
+
304
+ ### 3. Relationships
305
+
306
+ Define relationships between models using **relations** in your collection configuration. Relations are used only for **schema relationship definitions**. For automatically creating or linking related records in factories, use **associations** (see Factory Associations).
307
+
308
+ ```typescript
309
+ import { relations } from 'miragejs-orm';
310
+ ```
311
+
312
+ #### HasMany Relationship
313
+
314
+ Use `relations.hasMany()` to define a one-to-many relationship where a model has multiple related records.
315
+
316
+ ```typescript
317
+ // In users collection - define the relationship
318
+ relationships: {
319
+ posts: relations.hasMany(postModel),
320
+ }
321
+
322
+ // Usage - access related records
323
+ const post = testSchema.posts.create({ title: 'Hello' });
324
+ const user = testSchema.users.create({ name: 'Alice', posts: [post] });
325
+
326
+ console.log(user.posts); // ModelCollection with the post
327
+ console.log(user.posts.length); // 1
328
+ ```
329
+
330
+ #### BelongsTo Relationship
331
+
332
+ Use `relations.belongsTo()` to define a many-to-one relationship where a model belongs to another model.
333
+
334
+ ```typescript
335
+ // In posts collection - define the relationship
336
+ relationships: {
337
+ author: relations.belongsTo(userModel, { foreignKey: 'authorId' }),
338
+ }
339
+
340
+ // Usage - access the parent record
341
+ const post = testSchema.posts.find('1');
342
+ console.log(post.author.name); // Access related user
343
+ console.log(post.authorId); // The foreign key value
344
+ ```
345
+
346
+ #### Many-to-Many Relationships
347
+
348
+ For many-to-many relationships, use `relations.hasMany()` on both sides with array foreign keys.
349
+
350
+ ```typescript
351
+ import { model, schema, collection, relations } from 'miragejs-orm';
352
+
353
+ const studentModel = model('student', 'students')
354
+ .attrs<{ name: string; courseIds: string[] }>()
355
+ .build();
356
+
357
+ const courseModel = model('course', 'courses')
358
+ .attrs<{ title: string; studentIds: string[] }>()
359
+ .build();
360
+
361
+ const testSchema = schema()
362
+ .collections({
363
+ students: collection()
364
+ .model(studentModel)
365
+ .relationships({
366
+ courses: relations.hasMany(courseModel, {
367
+ foreignKey: 'courseIds',
368
+ }),
369
+ })
370
+ .build(),
371
+
372
+ courses: collection()
373
+ .model(courseModel)
374
+ .relationships({
375
+ students: relations.hasMany(studentModel, {
376
+ foreignKey: 'studentIds',
377
+ }),
378
+ })
379
+ .build(),
380
+ })
381
+ .build();
382
+
383
+ // Usage - bidirectional access
384
+ const student = testSchema.students.create({
385
+ name: 'Alice',
386
+ courseIds: ['1', '2'],
387
+ });
388
+ console.log(student.courses.length); // 2
389
+ ```
390
+
391
+ <details>
392
+ <summary><strong>Key points</strong></summary>
393
+
394
+ - **Same model template** — For correct behavior, the model template passed to the collection (`.model(...)`) and to the relationship utilities (`relations.hasMany(...)`, `relations.belongsTo(...)`) must be the same reference. Mismatches can break type inference and relationship resolution.
395
+ - **Default foreign key** — The default foreign key is derived from the **target model’s name** (from its template): `belongsTo(userModel)` → `userId`; `hasMany(postModel)` → `postIds`. You don’t need to pass `foreignKey` when your attribute matches this default.
396
+ - **Relationship name vs foreign key** — The relationship key (e.g. `author`) is independent of the stored attribute. If you want a different relationship name but the default FK is based on the target model name, specify `foreignKey` explicitly. Example: `author: relations.belongsTo(userModel, { foreignKey: 'authorId' })` uses the key `author` but stores the ID in `authorId`; without `foreignKey`, the default would be `userId` (from `userModel.modelName`).
397
+
398
+ </details>
399
+
400
+ ### 4. Factories
401
+
402
+ Factories help you generate realistic test data with minimal boilerplate.
403
+
404
+ #### Basic Factory
405
+
406
+ ```typescript
407
+ import { factory } from 'miragejs-orm';
408
+ import { faker } from '@faker-js/faker';
409
+
410
+ const userFactory = factory()
411
+ .model(userModel)
412
+ .attrs({
413
+ name: () => faker.person.fullName(),
414
+ email: () => faker.internet.email(),
415
+ role: 'user', // Static default
416
+ })
417
+ .build();
418
+ ```
419
+
420
+ #### Derived attributes with resolveFactoryAttr
421
+
422
+ When one attribute depends on another (e.g. email from name), use the **`resolveFactoryAttr`** helper inside an attr function. It resolves another attr:
423
+
424
+ - if that attr is a function, it calls it with the current model id;
425
+ - if it's a static value, it returns it.
426
+
427
+ That way you don't have to write the branching yourself: **it replaces** manual checks like `typeof this.name === 'function' ? this.name(id) : this.name` and keeps attr functions readable when they depend on other attrs.
428
+
429
+ ```typescript
430
+ import { factory, resolveFactoryAttr } from 'miragejs-orm';
431
+ import { faker } from '@faker-js/faker';
432
+
433
+ const userFactory = factory()
434
+ .model(userModel)
435
+ .attrs({
436
+ name: () => faker.person.fullName(),
437
+ email(id) {
438
+ const name = resolveFactoryAttr(this.name, id);
439
+ return `${name.split(' ').join('.').toLowerCase()}@example.com`;
440
+ },
441
+ role: 'user',
442
+ })
443
+ .build();
444
+
445
+ // Creates e.g. { name: 'John Doe', email: 'john.doe@example.com', role: 'user' }
446
+ testSchema.users.create();
447
+ ```
448
+
449
+ #### Traits
450
+
451
+ Traits allow you to create variations of your factory:
452
+
453
+ ```typescript
454
+ import { factory, associations } from 'miragejs-orm';
455
+ import { faker } from '@faker-js/faker';
456
+
457
+ const userFactory = factory()
458
+ .model(userModel)
459
+ .attrs({
460
+ name: () => faker.person.fullName(),
461
+ email: () => faker.internet.email(),
462
+ role: 'user',
463
+ })
464
+ .traits({
465
+ admin: {
466
+ role: 'admin',
467
+ },
468
+ verified: {
469
+ emailVerified: true,
470
+ afterCreate(model) {
471
+ // Custom logic after creation
472
+ model.update({ verifiedAt: new Date().toISOString() });
473
+ },
474
+ },
475
+ withPosts: {
476
+ posts: associations.createMany(postModel, 3),
477
+ },
478
+ })
479
+ .build();
480
+
481
+ // Usage
482
+ testSchema.users.create(); // Regular user
483
+ testSchema.users.create('admin'); // Admin user
484
+ testSchema.users.create('admin', 'verified'); // Admin + verified
485
+ testSchema.users.create('withPosts'); // User with 3 posts
486
+ ```
487
+
488
+ #### Factory Associations
489
+
490
+ Create related models automatically:
491
+
492
+ ```typescript
493
+ import { factory, associations } from 'miragejs-orm';
494
+
495
+ const userFactory = factory()
496
+ .model(userModel)
497
+ .associations({
498
+ // Create 3 identical posts
499
+ posts: associations.createMany(postModel, 3),
500
+ })
501
+ .traits({
502
+ withProfile: {
503
+ profile: associations.create(profileModel),
504
+ },
505
+ })
506
+ .build();
507
+
508
+ // Create multiple different related models
509
+ const authorFactory = factory()
510
+ .model(userModel)
511
+ .associations({
512
+ posts: associations.createMany(postModel, [
513
+ [{ title: 'First Post', published: true }],
514
+ [{ title: 'Draft Post', published: false }],
515
+ ['featured'], // Using a trait
516
+ ]),
517
+ })
518
+ .build();
519
+
520
+ // Link to existing models (or create if missing)
521
+ const userFactory2 = factory()
522
+ .model(userModel)
523
+ .traits({
524
+ withExistingPost: {
525
+ post: associations.link(postModel), // Finds first existing post, creates one if none exist
526
+ },
527
+ withExistingPosts: {
528
+ posts: associations.linkMany(postModel, 3), // Finds/creates up to 3 posts
529
+ },
530
+ })
531
+ .build();
532
+ ```
533
+
534
+ #### Lifecycle Hooks
535
+
536
+ Execute logic after model creation:
537
+
538
+ ```typescript
539
+ import { factory } from 'miragejs-orm';
540
+ import { faker } from '@faker-js/faker';
541
+
542
+ const postFactory = factory()
543
+ .model(postModel)
544
+ .attrs({
545
+ title: () => faker.lorem.sentence(),
546
+ content: () => faker.lorem.paragraphs(),
547
+ })
548
+ .afterCreate((post, schema) => {
549
+ // Automatically assign to first user
550
+ const user = schema.users.first();
551
+ if (user) {
552
+ post.update({ author: user });
553
+ }
554
+ })
555
+ .build();
556
+ ```
557
+
558
+ <details>
559
+ <summary><strong>Key points</strong></summary>
560
+
561
+ - **Build order** — When you call `collection.create(...)` (with optional traits/attrs), the library:
562
+ 1. **Factory** evaluates attrs and traits, resolves IDs, returns base attributes (no associations yet);
563
+ 2. **Collection** creates the model and **saves it to the DB** so the parent exists;
564
+ 3. **Factory** creates or links related models (they can now resolve the parent);
565
+ 4. **Collection** reloads the model and applies relationship FK updates (including inverse sync). So the parent is always saved before associations run.
566
+ - **Schema type for associations** — Pass your schema collections type to the factory so the `afterCreate` get full type inference and IDE support. Associations should be 'wired' separately:
567
+
568
+ ```typescript
569
+ factory<TestCollections>()
570
+ .model(userModel)
571
+ .associations({
572
+ posts: associations.createMany<PostModel, TestCollections>(
573
+ postModel,
574
+ 3,
575
+ 'published',
576
+ ), // model traits and attributes are suggested by IDE
577
+ })
578
+ .afterCreate((user, schema) => {
579
+ schema.posts.first(); // schema is fully typed
580
+ })
581
+ .build();
582
+ ```
583
+
584
+ </details>
585
+
586
+ ### 5. Schema
587
+
588
+ The schema is your in-memory database that ties everything together.
589
+
590
+ ```typescript
591
+ import { schema } from 'miragejs-orm';
592
+
593
+ const testSchema = schema()
594
+ .collections({
595
+ users: userCollection,
596
+ posts: postCollection,
597
+ comments: commentCollection,
598
+ })
599
+ .build();
600
+
601
+ // Now you can use all collections
602
+ testSchema.users.create({ name: 'Alice' });
603
+ testSchema.posts.all();
604
+ ```
605
+
606
+ #### Fixtures
607
+
608
+ Load initial data from fixtures. Fixtures are defined at the **collection level** and support a `strategy` option to control when they're loaded:
609
+
610
+ - `'manual'` (default) - Load fixtures manually by calling `loadFixtures()`
611
+ - `'auto'` - Load fixtures automatically during schema setup
612
+
613
+ ```typescript
614
+ import { schema, collection } from 'miragejs-orm';
615
+
616
+ // Manual loading (default)
617
+ const testSchema = schema()
618
+ .collections({
619
+ users: collection()
620
+ .model(userModel)
621
+ .fixtures([
622
+ { id: '1', name: 'Alice', email: 'alice@example.com' },
623
+ { id: '2', name: 'Bob', email: 'bob@example.com' },
624
+ ])
625
+ .build(),
626
+ })
627
+ .build();
628
+
629
+ // Load fixtures manually when needed
630
+ testSchema.loadFixtures(); // Loads all collection fixtures
631
+ testSchema.users.loadFixtures(); // Or load for specific collection
632
+
633
+ // Automatic loading with strategy option
634
+ const autoSchema = schema()
635
+ .collections({
636
+ users: collection()
637
+ .model(userModel)
638
+ .fixtures(
639
+ [
640
+ { id: '1', name: 'Alice', email: 'alice@example.com' },
641
+ { id: '2', name: 'Bob', email: 'bob@example.com' },
642
+ ],
643
+ { strategy: 'auto' }, // Fixtures load automatically during setup
644
+ )
645
+ .build(),
646
+ })
647
+ .build(); // Fixtures are already loaded!
648
+ ```
649
+
650
+ #### Seeds
651
+
652
+ Define seed scenarios at the collection level for different testing contexts:
653
+
654
+ ```typescript
655
+ import { collection, schema } from 'miragejs-orm';
656
+ import { faker } from '@faker-js/faker';
657
+
658
+ // Define seeds in the collection builder. Use a "default" scenario for loadSeeds({ onlyDefault: true }).
659
+ const userCollection = collection()
660
+ .model(userModel)
661
+ .factory(userFactory)
662
+ .seeds({
663
+ default: (schema) => {
664
+ // Loaded when calling testSchema.loadSeeds({ onlyDefault: true })
665
+ schema.users.create({
666
+ name: 'Demo User',
667
+ email: 'demo@example.com',
668
+ role: 'user',
669
+ });
670
+ },
671
+ userForm: (schema) => {
672
+ // Create a user with all fields populated for form testing
673
+ schema.users.create({
674
+ name: 'John Doe',
675
+ email: 'john.doe@example.com',
676
+ role: 'admin',
677
+ bio: 'Software developer with 10 years of experience',
678
+ avatar: 'https://i.pravatar.cc/150?img=12',
679
+ isActive: true,
680
+ createdAt: new Date('2024-01-15').toISOString(),
681
+ });
682
+ },
683
+
684
+ adminUser: (schema) => {
685
+ // Create admin user for permission testing
686
+ schema.users.create({
687
+ name: 'Admin User',
688
+ email: 'admin@example.com',
689
+ role: 'admin',
690
+ });
691
+ },
692
+ })
693
+ .build();
694
+
695
+ const postCollection = collection()
696
+ .model(postModel)
697
+ .factory(postFactory)
698
+ .seeds({
699
+ postAuthor: (schema) => {
700
+ // Create posts and assign a user to a random subset
701
+ schema.posts.createMany(20);
702
+ const user = schema.users.create({
703
+ name: 'Alice Author',
704
+ email: 'alice@example.com',
705
+ });
706
+
707
+ // Assign user to random 5 posts
708
+ const allPosts = schema.posts.all().models;
709
+ const randomPosts = faker.helpers.arrayElements(allPosts, 5);
710
+
711
+ randomPosts.forEach((post) => {
712
+ post.update({ author: user });
713
+ });
714
+ },
715
+ })
716
+ .build();
717
+
718
+ const testSchema = schema()
719
+ .collections({
720
+ users: userCollection,
721
+ posts: postCollection,
722
+ })
723
+ .build();
724
+
725
+ // Load all seeds for all collections
726
+ await testSchema.loadSeeds();
727
+
728
+ // Or load seeds for a specific collection
729
+ await testSchema.users.loadSeeds();
730
+
731
+ // Or load a specific scenario for a collection
732
+ await testSchema.users.loadSeeds('userForm');
733
+ await testSchema.posts.loadSeeds('postAuthor');
734
+
735
+ // Load only default scenarios (e.g. for development mock server)
736
+ await testSchema.loadSeeds({ onlyDefault: true });
737
+ ```
738
+
739
+ <details>
740
+ <summary><strong>Key points</strong></summary>
741
+
742
+ - **Create models only through the schema collection API** — Use `testSchema.users.create()`, `testSchema.posts.find()`, etc. Avoid creating or mutating data only via `schema.db`; go through collections so relationships, serializers, and identity managers stay consistent.
743
+ - **Default vs named seeds** — Use the **default** seed scenario for the development environment (e.g. `loadSeeds({ onlyDefault: true })` when starting the mock server). Use **named** seed scenarios for specific test cases (e.g. `loadSeeds('userForm')` or `loadSeeds('postAuthor')`). Combining both gives you a stable dev dataset and targeted test data.
744
+
745
+ </details>
746
+
747
+ ### 6. Serializers
748
+
749
+ Serializers control how models are converted to JSON. Configure them per collection via the **Serializer** class or by passing a **SerializerConfig** object. Options can be overridden at call time (e.g. `model.toJSON()` or `model.serializer(options)`).
750
+
751
+ #### Serializer options (SerializerConfig)
752
+
753
+ - **`select`** — Which attributes to include.
754
+
755
+ - **Array:** include only listed keys, e.g. `['id', 'name', 'email']`.
756
+ - **Object:** `{ key: true }` include only those keys; `{ key: false }` exclude those keys (include all others).
757
+
758
+ - **`with`** — Which relationships to include and how.
759
+
760
+ - **Array:** relationship names to include, e.g. `['posts', 'author']`.
761
+ - **Object:** `{ relationName: true }` or `{ relationName: false }` to include/exclude; or `{ relationName: { select: [...], mode: 'embedded' } }` for nested options (e.g. which fields on the relation, or per-relation mode override).
762
+
763
+ - **`relationsMode`** — How to output relationships (default: `'foreignKey'`).
764
+
765
+ - `'foreignKey'` — Only foreign key IDs; no nested relationship data.
766
+ - `'embedded'` — Relationships nested in the model; foreign keys removed.
767
+ - `'embedded+foreignKey'` — Nested and keep foreign keys.
768
+ - `'sideLoaded'` — Relationships at top level (requires `root`). `BelongsTo` as single-item arrays.
769
+ - `'sideLoaded+foreignKey'` — Same and keep foreign keys in attributes.
770
+ - Per-relation overrides are possible inside `with` (e.g. `with: { posts: { mode: 'embedded' } }`).
771
+
772
+ - **`root`** — Wrap the serialized output in a root key.
773
+ - `true` — Use model/collection name (e.g. `{ user: { ... } }`).
774
+ - `false` — No wrapping (default).
775
+ - **String** — Custom key (e.g. `'userData'` → `{ userData: { ... } }`).
776
+
777
+ #### Using the Serializer class
778
+
779
+ ```typescript
780
+ import { model, collection, schema, Serializer } from 'miragejs-orm';
781
+ import type { SerializerConfig } from 'miragejs-orm';
782
+
783
+ interface UserAttrs {
784
+ id: string;
785
+ name: string;
786
+ email: string;
787
+ password: string;
788
+ role: string;
789
+ }
790
+
791
+ interface UserJSON {
792
+ id: string;
793
+ name: string;
794
+ email: string;
795
+ }
796
+
797
+ const userModel = model('user', 'users').attrs<UserAttrs>().build();
798
+
799
+ const userSerializer = new Serializer(userModel, {
800
+ select: ['id', 'name', 'email'],
801
+ with: { posts: true },
802
+ relationsMode: 'embedded',
803
+ root: true,
804
+ });
805
+
806
+ const testSchema = schema()
807
+ .collections({
808
+ users: collection().model(userModel).serializer(userSerializer).build(),
809
+ })
810
+ .build();
811
+
812
+ const user = testSchema.users.create({
813
+ name: 'Alice',
814
+ email: 'alice@example.com',
815
+ password: 'secret',
816
+ role: 'admin',
817
+ });
818
+
819
+ const json = user.toJSON();
820
+ // With root: true → { user: { id: '1', name: 'Alice', email: 'alice@example.com', posts: [...] } }
821
+ ```
822
+
823
+ #### Collection-level serializer config (object)
824
+
825
+ Pass options directly to the collection instead of a Serializer instance. The collection builds an internal serializer from this config.
826
+
827
+ ```typescript
828
+ const testSchema = schema()
829
+ .collections({
830
+ users: collection()
831
+ .model(userModel)
832
+ .serializer({
833
+ select: ['id', 'name', 'email'],
834
+ root: 'userData',
835
+ with: ['posts'],
836
+ relationsMode: 'embedded',
837
+ })
838
+ .build(),
839
+ })
840
+ .build();
841
+
842
+ const user = testSchema.users.create({ name: 'Bob', email: 'bob@example.com' });
843
+ console.log(user.toJSON());
844
+ // { userData: { id: '1', name: 'Bob', email: 'bob@example.com', posts: [...] } }
845
+ ```
846
+
847
+ #### Method-level overrides
848
+
849
+ Override serializer options at call time using the **model’s** `serialize()` method (or `toJSON()` for default options). The model uses the collection’s serializer and passes through your overrides:
850
+
851
+ ```typescript
852
+ // Override options when serializing (uses the collection’s serializer)
853
+ const json = user.serialize({ root: false });
854
+ ```
855
+
856
+ <details>
857
+ <summary><strong>Key points (vs original MirageJS)</strong></summary>
858
+
859
+ - **Built-in serialization; use model methods** — Unlike legacy MirageJS, you don’t need to call serializers directly. Pass the serializer (or config) in the collection config and use **model methods only**: `model.toJSON()`, `model.serialize(options)`, and `collection.toJSON()` for lists. Serialization is built into the model and collection.
860
+ - **Config-based API** — Serialization is driven by **select**, **with**, **relationsMode**, and **root** (no separate `attrs` / `embed` / `include`). Relationship inclusion and shape are controlled by `with` and `relationsMode` (e.g. `embedded`, `foreignKey`, `sideLoaded`).
861
+ - **One level of nested relationships** — Only one level of nested `with` is supported when serializing related models. Deeper nesting is not part of the serializer API; keep payloads one level for related data.
862
+ - **Custom reusable serializers** — Use the **Serializer** class to define a named, reusable serializer (with your own options and optional custom subclass). Attach it to a collection or use it in tests so the same output shape is reused across test files and handlers.
863
+
864
+ </details>
865
+
866
+ ### 7. Records vs Models
867
+
868
+ Understanding the distinction between **Records** and **Models** is fundamental to working with miragejs-orm:
869
+
870
+ **Records** are plain JavaScript objects stored in the database (`DbCollection`). They contain:
871
+
872
+ - Simple data attributes (name, email, etc.)
873
+ - Foreign keys (userId, postIds, etc.)
874
+ - An `id` field
875
+ - No methods or behavior
876
+
877
+ **Models** are class instances that wrap records and provide rich functionality:
878
+
879
+ - All record attributes via accessors (`user.name`, `post.title`)
880
+ - Relationship accessors (`user.posts`, `post.author`)
881
+ - CRUD methods (`.save()`, `.update()`, `.destroy()`, `.reload()`)
882
+ - Relationship methods (`.related()`, `.link()`, `.unlink()`)
883
+ - Serialization (`.toJSON()`, `.toString()`)
884
+ - Status tracking (`.isNew()`, `.isSaved()`)
885
+
886
+ ```typescript
887
+ // When you create a model, it materializes into a Model instance
888
+ const user = testSchema.users.create({
889
+ name: 'Alice',
890
+ email: 'alice@example.com',
891
+ });
892
+
893
+ // The Model instance wraps a Record stored in the database
894
+ console.log(user instanceof Model); // true
895
+ console.log(user.name); // 'Alice' - attribute accessor
896
+ console.log(user.posts); // ModelCollection - relationship accessor
897
+
898
+ // Under the hood, the record is just:
899
+ // { id: '1', name: 'Alice', email: 'alice@example.com', postIds: [] }
900
+
901
+ // Models are materialized when:
902
+ // - Creating: testSchema.users.create(...)
903
+ // - Finding: testSchema.users.find('1')
904
+ // - Querying: testSchema.users.findMany({ where: ... })
905
+ // - Accessing relationships: user.posts (returns ModelCollection of Models)
906
+ ```
907
+
908
+ **Why This Matters:**
909
+
910
+ - 🗄️ **Storage Efficiency** - The database stores lightweight records, not heavy model instances
911
+ - 🔄 **Fresh Data** - Each query materializes new model instances with the latest record data
912
+ - 🎯 **Type Safety** - Models provide type-safe accessors and methods, records are just data
913
+ - 🔗 **Relationships** - Models handle relationship logic, records only store foreign keys
914
+
915
+ ---
916
+
917
+ <div align="center">
918
+
919
+ ## 🎯 Usage Examples
920
+
921
+ </div>
922
+
923
+ ### With MSW (Mock Service Worker)
924
+
925
+ ```typescript
926
+ import { http, HttpResponse } from 'msw';
927
+ import { setupServer } from 'msw/node';
928
+ import { schema, model, collection, factory } from 'miragejs-orm';
929
+
930
+ // Setup your schema
931
+ const testSchema = schema()
932
+ .collections({
933
+ users: collection().model(userModel).factory(userFactory).build(),
934
+ })
935
+ .build();
936
+
937
+ // Seed data
938
+ testSchema.users.createMany(10);
939
+
940
+ // Create MSW handlers
941
+ const handlers = [
942
+ http.get('/api/users', () => {
943
+ const users = testSchema.users.all();
944
+ return HttpResponse.json({ users: users.toJSON() });
945
+ }),
946
+
947
+ http.get('/api/users/:id', ({ params }) => {
948
+ const user = testSchema.users.find(params.id as string);
949
+ if (!user) {
950
+ return new HttpResponse(null, { status: 404 });
951
+ }
952
+ return HttpResponse.json(user.toJSON());
953
+ }),
954
+
955
+ http.post('/api/users', async ({ request }) => {
956
+ const body = await request.json();
957
+ const user = testSchema.users.create(body);
958
+ return HttpResponse.json(user.toJSON(), { status: 201 });
959
+ }),
960
+ ];
961
+
962
+ const server = setupServer(...handlers);
963
+ ```
964
+
965
+ ### In Testing (Jest)
966
+
967
+ With Jest, use a shared schema and reset the database in `beforeEach` so each test starts with a clean state:
968
+
969
+ ```typescript
970
+ import { describe, it, expect, beforeEach } from '@jest/globals';
971
+ import { testSchema } from '@test/schema';
972
+
973
+ describe('User Management', () => {
974
+ beforeEach(() => {
975
+ testSchema.db.emptyData();
976
+ });
977
+
978
+ it('should create a user with posts', () => {
979
+ const user = testSchema.users.create({
980
+ name: 'Alice',
981
+ email: 'alice@example.com',
982
+ });
983
+
984
+ testSchema.posts.createMany(3, { authorId: user.id });
985
+
986
+ expect(user.posts.length).toBe(3);
987
+ expect(user.posts.models[0].author.id).toBe(user.id);
988
+ });
989
+
990
+ it('should handle complex relationships', () => {
991
+ const user1 = testSchema.users.create({ name: 'Alice' });
992
+ const user2 = testSchema.users.create({ name: 'Bob' });
993
+
994
+ const post = testSchema.posts.create({
995
+ title: 'Hello',
996
+ authorId: user1.id,
997
+ });
998
+
999
+ testSchema.comments.create({
1000
+ content: 'Great post!',
1001
+ postId: post.id,
1002
+ userId: user2.id,
1003
+ });
1004
+
1005
+ expect(post.comments.length).toBe(1);
1006
+ expect(post.comments.models[0].user.name).toBe('Bob');
1007
+ });
1008
+ });
1009
+ ```
1010
+
1011
+ ### In Testing (Vitest Context)
1012
+
1013
+ With Vitest, you can inject the schema as a **fixture** so each test receives a `schema` argument and the DB is cleared after the test automatically. Extend Vitest’s `test` and use the extended `test` from your context:
1014
+
1015
+ ```typescript
1016
+ // test/context.ts
1017
+ import { test as baseTest } from 'vitest';
1018
+ import { testSchema } from './schema';
1019
+
1020
+ export const test = baseTest.extend<{ schema: typeof testSchema }>({
1021
+ schema: async ({}, use) => {
1022
+ await use(testSchema);
1023
+ testSchema.db.emptyData(); // Teardown: clean after each test
1024
+ },
1025
+ });
1026
+ ```
1027
+
1028
+ ```typescript
1029
+ // features/users/api/createUser.test.ts
1030
+ import { test, describe, expect } from '@test/context';
1031
+
1032
+ describe('createUser', () => {
1033
+ test('creates user and returns serialized', async ({ schema }) => {
1034
+ const result = await createUser({
1035
+ name: 'Alice',
1036
+ email: 'alice@example.com',
1037
+ });
1038
+ const user = schema.users.find(result.id)!;
1039
+
1040
+ expect(result).toMatchObject({ name: user.name, email: user.email });
1041
+ expect(user).toBeDefined();
1042
+ });
1043
+
1044
+ test('can create posts for user', async ({ schema }) => {
1045
+ const user = schema.users.create({ name: 'Bob', email: 'bob@example.com' });
1046
+ schema.posts.createMany(2, { authorId: user.id });
1047
+
1048
+ expect(user.posts.length).toBe(2);
1049
+ });
1050
+ });
1051
+ ```
1052
+
1053
+ Each test gets a fresh logical state: the same `testSchema` instance is used, and `emptyData()` runs after the fixture’s `use()` completes.
1054
+
1055
+ ### In Storybook
1056
+
1057
+ ```typescript
1058
+ import type { Meta, StoryObj } from '@storybook/react';
1059
+ import { UserList } from './UserList';
1060
+ import { schema, model, collection, factory } from 'miragejs-orm';
1061
+
1062
+ // Setup mock data for stories
1063
+ const setupMockData = (count: number) => {
1064
+ const testSchema = schema()
1065
+ .collections({
1066
+ users: collection()
1067
+ .model(userModel)
1068
+ .factory(userFactory)
1069
+ .build(),
1070
+ })
1071
+ .build();
1072
+
1073
+ return testSchema.users.createMany(count);
1074
+ };
1075
+
1076
+ const meta: Meta<typeof UserList> = {
1077
+ component: UserList,
1078
+ title: 'UserList',
1079
+ };
1080
+
1081
+ export default meta;
1082
+
1083
+ export const Empty: StoryObj<typeof UserList> = {
1084
+ render: () => <UserList users={[]} />,
1085
+ };
1086
+
1087
+ export const WithUsers: StoryObj<typeof UserList> = {
1088
+ render: () => {
1089
+ const users = setupMockData(5);
1090
+ return <UserList users={users.toJSON()} />;
1091
+ },
1092
+ };
1093
+
1094
+ export const WithManyUsers: StoryObj<typeof UserList> = {
1095
+ render: () => {
1096
+ const users = setupMockData(50);
1097
+ return <UserList users={users.toJSON()} />;
1098
+ },
1099
+ };
1100
+ ```
1101
+
1102
+ ---
1103
+
1104
+ <div align="center">
1105
+
1106
+ ## 🔧 Advanced Features
1107
+
1108
+ </div>
1109
+
1110
+ ### Custom Identity Managers
1111
+
1112
+ Control how IDs are generated via **IdentityManagerConfig** (no need to define a custom class). You can set a default at the **schema level** and override per **collection**.
1113
+
1114
+ **Config options:** `initialCounter` (required), optional `initialUsedIds`, optional `idGenerator(currentId) => nextId`.
1115
+
1116
+ ```typescript
1117
+ import { schema, collection, IdentityManager } from 'miragejs-orm';
1118
+ import type { IdentityManagerConfig } from 'miragejs-orm';
1119
+
1120
+ // Global default: string IDs starting at "1" (schema-level)
1121
+ const testSchema = schema()
1122
+ .identityManager({ initialCounter: '1' })
1123
+ .collections({
1124
+ users: userCollection,
1125
+ posts: postCollection,
1126
+ })
1127
+ .build();
1128
+
1129
+ // Per-collection override: number IDs for posts only
1130
+ const postCollection = collection()
1131
+ .model(postModel)
1132
+ .identityManager({ initialCounter: 1 })
1133
+ .build();
1134
+
1135
+ // Custom id generator (e.g. UUIDs)
1136
+ const uuidConfig: IdentityManagerConfig<string> = {
1137
+ initialCounter: '0',
1138
+ idGenerator: () => crypto.randomUUID(),
1139
+ };
1140
+ const usersCollection = collection()
1141
+ .model(userModel)
1142
+ .identityManager(uuidConfig)
1143
+ .build();
1144
+
1145
+ // You can also pass an IdentityManager instance per collection
1146
+ const customManager = new IdentityManager({ initialCounter: 100 });
1147
+ collection().model(postModel).identityManager(customManager).build();
1148
+ ```
1149
+
1150
+ ### Custom Serializers
1151
+
1152
+ Extend the **Serializer** class when you need a reusable, named serializer with custom logic. Override `serialize()` or `serializeCollection()` and call `super.serialize()` / `super.serializeCollection()` to use the default behavior, then adjust the result. When the custom serializer is passed to the collection, you can use **`.toJSON()`** on the model or collection instead of calling the serializer directly.
1153
+
1154
+ ```typescript
1155
+ import { model, collection, schema, Serializer } from 'miragejs-orm';
1156
+ import type {
1157
+ SerializerConfig,
1158
+ ModelInstance,
1159
+ ModelCollection,
1160
+ } from 'miragejs-orm';
1161
+
1162
+ // Custom serialized types (optional — use .json() so toJSON() is typed)
1163
+ interface UserJSON {
1164
+ id: string;
1165
+ name: string;
1166
+ email: string;
1167
+ role: string;
1168
+ displayName: string;
1169
+ }
1170
+ interface UserListResponse {
1171
+ data: UserJSON[];
1172
+ }
1173
+
1174
+ const userModel = model('user', 'users')
1175
+ .attrs<{ id: string; name: string; email: string; role: string }>()
1176
+ .json<UserJSON, UserJSON[]>()
1177
+ .build();
1178
+
1179
+ // Custom serializer: add computed fields; wrap list response in a "data" envelope
1180
+ class UserApiSerializer extends Serializer<typeof userModel, TestCollections> {
1181
+ serialize(
1182
+ model: ModelInstance<typeof userModel, TestCollections>,
1183
+ options?: Partial<SerializerConfig<typeof userModel, TestCollections>>,
1184
+ ) {
1185
+ const json = super.serialize(model, options) as Record<string, unknown>;
1186
+ json.displayName = `${model.name} (${model.email})`;
1187
+ return json;
1188
+ }
1189
+
1190
+ serializeCollection(
1191
+ collection: ModelCollection<typeof userModel, TestCollections>,
1192
+ options?: Partial<SerializerConfig<typeof userModel, TestCollections>>,
1193
+ ) {
1194
+ const json = super.serializeCollection(collection, options) as Record<
1195
+ string,
1196
+ unknown
1197
+ >;
1198
+ const items = json[this.collectionName] as unknown[];
1199
+ return { data: items };
1200
+ }
1201
+ }
1202
+
1203
+ const userSerializer = new UserApiSerializer(userModel, {
1204
+ select: ['id', 'name', 'email', 'role'],
1205
+ root: true,
1206
+ });
1207
+
1208
+ const testSchema = schema()
1209
+ .collections({
1210
+ users: collection().model(userModel).serializer(userSerializer).build(),
1211
+ })
1212
+ .build();
1213
+
1214
+ const user = testSchema.users.create({
1215
+ name: 'Alice',
1216
+ email: 'alice@example.com',
1217
+ role: 'user',
1218
+ });
1219
+ const users = testSchema.users.all();
1220
+
1221
+ // Option 1: use .toJSON() (uses the collection's serializer)
1222
+ const singleJson: { user: UserJSON } = user.toJSON();
1223
+ // { user: { id: '1', name: 'Alice', email: 'alice@example.com', role: 'user', displayName: 'Alice (alice@example.com)' } }
1224
+
1225
+ const listJson: UserListResponse = users.toJSON() as UserListResponse;
1226
+ // { data: [{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'user', displayName: '...' }, ...] }
1227
+
1228
+ // Option 2: call the serializer directly (e.g. in handlers or when you hold a serializer reference)
1229
+ userSerializer.serialize(user);
1230
+ userSerializer.serializeCollection(users);
1231
+ ```
1232
+
1233
+ ### Query Methods
1234
+
1235
+ MirageJS ORM provides powerful query capabilities including advanced operators, logical combinations, and pagination — features not available in standard MirageJS.
1236
+
1237
+ #### Basic Queries
1238
+
1239
+ ```typescript
1240
+ // Simple equality
1241
+ const admins = testSchema.users.findMany({ where: { role: 'admin' } });
1242
+
1243
+ // Predicate function
1244
+ const recentPosts = testSchema.posts.findMany({
1245
+ where: (post) => new Date(post.createdAt) > new Date('2024-01-01'),
1246
+ });
1247
+ ```
1248
+
1249
+ #### Advanced Query Operators
1250
+
1251
+ ```typescript
1252
+ // Comparison operators
1253
+ const youngUsers = testSchema.users.findMany({
1254
+ where: { age: { lt: 30 } }, // Less than
1255
+ });
1256
+
1257
+ const adults = testSchema.users.findMany({
1258
+ where: { age: { gte: 18 } }, // Greater than or equal
1259
+ });
1260
+
1261
+ const rangeUsers = testSchema.users.findMany({
1262
+ where: { age: { between: [25, 35] } }, // Between (inclusive)
1263
+ });
1264
+
1265
+ // String operators
1266
+ const gmailUsers = testSchema.users.findMany({
1267
+ where: { email: { like: '%@gmail.com' } }, // SQL-style wildcards
1268
+ });
1269
+
1270
+ const nameSearch = testSchema.users.findMany({
1271
+ where: { name: { ilike: '%john%' } }, // Case-insensitive search
1272
+ });
1273
+
1274
+ const usersStartingWithA = testSchema.users.findMany({
1275
+ where: { name: { startsWith: 'A' } },
1276
+ });
1277
+
1278
+ // Null checks
1279
+ const usersWithoutEmail = testSchema.users.findMany({
1280
+ where: { email: { isNull: true } },
1281
+ });
1282
+
1283
+ // Array operators
1284
+ const admins = testSchema.users.findMany({
1285
+ where: { tags: { contains: 'admin' } }, // Array includes value
1286
+ });
1287
+
1288
+ const multipleRoles = testSchema.users.findMany({
1289
+ where: { tags: { contains: ['admin', 'moderator'] } }, // Array includes all values
1290
+ });
1291
+ ```
1292
+
1293
+ **Available Operators:**
1294
+
1295
+ - Equality: `eq`, `ne`, `in`, `nin`, `isNull`
1296
+ - Comparison: `lt`, `lte`, `gt`, `gte`, `between`
1297
+ - String: `like`, `ilike`, `startsWith`, `endsWith`, `contains`
1298
+ - Array: `contains`, `length`
1299
+
1300
+ #### Logical Operators (AND/OR/NOT)
1301
+
1302
+ ```typescript
1303
+ // AND - all conditions must match
1304
+ const activeAdmins = testSchema.users.findMany({
1305
+ where: {
1306
+ AND: [{ status: 'active' }, { role: 'admin' }],
1307
+ },
1308
+ });
1309
+
1310
+ // OR - at least one condition must match
1311
+ const flaggedUsers = testSchema.users.findMany({
1312
+ where: {
1313
+ OR: [{ status: 'suspended' }, { age: { lt: 18 } }],
1314
+ },
1315
+ });
1316
+
1317
+ // NOT - negate condition
1318
+ const nonAdmins = testSchema.users.findMany({
1319
+ where: {
1320
+ NOT: { role: 'admin' },
1321
+ },
1322
+ });
1323
+
1324
+ // Complex combinations
1325
+ const eligibleUsers = testSchema.users.findMany({
1326
+ where: {
1327
+ AND: [
1328
+ {
1329
+ OR: [{ status: 'active' }, { status: 'pending' }],
1330
+ },
1331
+ { NOT: { age: { lt: 18 } } },
1332
+ { email: { like: '%@company.com' } },
1333
+ ],
1334
+ },
1335
+ });
1336
+ ```
1337
+
1338
+ #### Pagination
1339
+
1340
+ **Offset-based (Standard)**
1341
+
1342
+ ```typescript
1343
+ // Page 1: First 10 users
1344
+ const page1 = testSchema.users.findMany({
1345
+ limit: 10,
1346
+ offset: 0,
1347
+ });
1348
+
1349
+ // Page 2: Next 10 users
1350
+ const page2 = testSchema.users.findMany({
1351
+ limit: 10,
1352
+ offset: 10,
1353
+ });
1354
+
1355
+ // Combined with filtering and sorting
1356
+ const activeUsersPage2 = testSchema.users.findMany({
1357
+ where: { status: 'active' },
1358
+ orderBy: { createdAt: 'desc' },
1359
+ offset: 20,
1360
+ limit: 10,
1361
+ });
1362
+ ```
1363
+
1364
+ **Cursor-based (Keyset) Pagination**
1365
+
1366
+ More efficient for large datasets and prevents inconsistencies when data changes between requests.
1367
+
1368
+ ```typescript
1369
+ // First page
1370
+ const firstPage = testSchema.users.findMany({
1371
+ orderBy: { createdAt: 'desc' },
1372
+ limit: 10,
1373
+ });
1374
+
1375
+ // Next page using last item as cursor
1376
+ const lastUser = firstPage[firstPage.length - 1];
1377
+ const nextPage = testSchema.users.findMany({
1378
+ orderBy: { createdAt: 'desc' },
1379
+ cursor: { createdAt: lastUser.createdAt },
1380
+ limit: 10,
1381
+ });
1382
+
1383
+ // Multi-field cursor for unique sorting
1384
+ const page = testSchema.users.findMany({
1385
+ orderBy: [
1386
+ ['score', 'desc'],
1387
+ ['createdAt', 'asc'],
1388
+ ],
1389
+ cursor: { score: 100, createdAt: new Date('2024-01-15') },
1390
+ limit: 20,
1391
+ });
1392
+ ```
1393
+
1394
+ #### Combined Operations
1395
+
1396
+ ```typescript
1397
+ // Complex query with all features
1398
+ const results = testSchema.users.findMany({
1399
+ where: {
1400
+ AND: [
1401
+ { status: 'active' },
1402
+ {
1403
+ OR: [{ role: 'admin' }, { tags: { contains: 'premium' } }],
1404
+ },
1405
+ { age: { gte: 18 } },
1406
+ ],
1407
+ },
1408
+ orderBy: [
1409
+ ['lastActive', 'desc'],
1410
+ ['name', 'asc'],
1411
+ ],
1412
+ offset: 20,
1413
+ limit: 10,
1414
+ });
1415
+ ```
1416
+
1417
+ ### Direct Database Access
1418
+
1419
+ For low-level operations:
1420
+
1421
+ ```typescript
1422
+ // Access raw database
1423
+ const rawUsers = testSchema.db.users.all();
1424
+
1425
+ // Batch operations
1426
+ testSchema.db.emptyData(); // Clear all data
1427
+ testSchema.db.users.insert({ id: '1', name: 'Alice' });
1428
+ testSchema.db.users.remove({ id: '1' });
1429
+ ```
1430
+
1431
+ ### Debugging
1432
+
1433
+ Enable logging to understand what the ORM is doing under the hood. This is invaluable for debugging tests, understanding query behavior, and troubleshooting data issues.
1434
+
1435
+ **Enable Logging:**
1436
+
1437
+ ```typescript
1438
+ import { schema, LogLevel } from 'miragejs-orm';
1439
+
1440
+ const testSchema = schema()
1441
+ .collections({
1442
+ users: userCollection,
1443
+ posts: postCollection,
1444
+ })
1445
+ .logging({
1446
+ enabled: true,
1447
+ level: LogLevel.DEBUG, // or 'debug'
1448
+ })
1449
+ .build();
1450
+ ```
1451
+
1452
+ **Log Levels:** Use the `LogLevel` enum or string equivalents.
1453
+
1454
+ ```typescript
1455
+ import { schema, LogLevel } from 'miragejs-orm';
1456
+
1457
+ // Debug - Most verbose, shows all operations
1458
+ schema().logging({ enabled: true, level: LogLevel.DEBUG });
1459
+ // Output: Schema initialization, collection registration, create/find operations, query details
1460
+
1461
+ // Info - Important operations and results
1462
+ schema().logging({ enabled: true, level: LogLevel.INFO });
1463
+ // Output: Fixtures loaded, seeds loaded, high-level operations
1464
+
1465
+ // Warn - Potential issues and unusual patterns
1466
+ schema().logging({ enabled: true, level: LogLevel.WARN });
1467
+ // Output: Foreign key mismatches, deprecated usage
1468
+
1469
+ // Error - Only failures and validation errors
1470
+ schema().logging({ enabled: true, level: LogLevel.ERROR });
1471
+ // Output: Operation failures, validation errors
1472
+
1473
+ // Silent - No logging (default)
1474
+ schema().logging({ enabled: true, level: LogLevel.SILENT });
1475
+ ```
1476
+
1477
+ **Custom Prefix:**
1478
+
1479
+ ```typescript
1480
+ import { schema } from 'miragejs-orm';
1481
+
1482
+ const testSchema = schema()
1483
+ .collections({ users: userCollection })
1484
+ .logging({
1485
+ enabled: true,
1486
+ level: 'debug',
1487
+ prefix: '[MyApp Test]', // Custom prefix instead of default '[Mirage]'
1488
+ })
1489
+ .build();
1490
+
1491
+ // Output: [MyApp Test] DEBUG: Schema initialized
1492
+ ```
1493
+
1494
+ **What Gets Logged:**
1495
+
1496
+ ```typescript
1497
+ import { schema, collection, LogLevel } from 'miragejs-orm';
1498
+
1499
+ const testSchema = schema()
1500
+ .logging({ enabled: true, level: LogLevel.DEBUG })
1501
+ .collections({
1502
+ users: collection()
1503
+ .model(userModel)
1504
+ .factory(userFactory)
1505
+ .fixtures([{ id: '1', name: 'Alice' }])
1506
+ .seeds({ testData: (schema) => schema.users.create({ name: 'Bob' }) })
1507
+ .build(),
1508
+ })
1509
+ .build();
1510
+
1511
+ // Console output:
1512
+ // [Mirage] DEBUG: Registering collections { count: 1, names: ['users'] }
1513
+ // [Mirage] DEBUG: Collection 'users' initialized { modelName: 'user' }
1514
+ // [Mirage] DEBUG: Schema initialized { collections: ['users'] }
1515
+
1516
+ // Load fixtures
1517
+ testSchema.loadFixtures();
1518
+ // [Mirage] INFO: Fixtures loaded successfully for 'users' { count: 1 }
1519
+
1520
+ // Create a record
1521
+ testSchema.users.create({ name: 'Charlie' });
1522
+ // [Mirage] DEBUG: Creating user { collection: 'users' }
1523
+ // [Mirage] DEBUG: Created user with factory { collection: 'users', id: '2' }
1524
+
1525
+ // Query records
1526
+ const users = testSchema.users.findMany({ where: { name: 'Charlie' } });
1527
+ // [Mirage] DEBUG: Query 'users': findMany
1528
+ // [Mirage] DEBUG: Query 'users' returned 1 records
1529
+
1530
+ // Load seeds for a specific scenario
1531
+ testSchema.users.loadSeeds('testData');
1532
+ // [Mirage] INFO: Seeds loaded successfully for 'users' { scenario: 'testData' }
1533
+ ```
1534
+
1535
+ **Use Cases:**
1536
+
1537
+ ```typescript
1538
+ import { schema, LogLevel } from 'miragejs-orm';
1539
+
1540
+ // Development - See what's happening
1541
+ const devSchema = schema()
1542
+ .collections({ users: userCollection })
1543
+ .logging({ enabled: true, level: LogLevel.INFO })
1544
+ .build();
1545
+
1546
+ // Testing - Debug failing tests
1547
+ const testSchema = schema()
1548
+ .collections({ users: userCollection })
1549
+ .logging({
1550
+ enabled: process.env.DEBUG === 'true', // Enable via env var
1551
+ level: LogLevel.DEBUG,
1552
+ })
1553
+ .build();
1554
+ ```
1555
+
1556
+ ---
1557
+
1558
+ <div align="center">
1559
+
1560
+ ## 💡 TypeScript Best Practices
1561
+
1562
+ </div>
1563
+
1564
+ MirageJS ORM is built with TypeScript-first design. Here are best practices for getting the most out of type safety.
1565
+
1566
+ ### Defining Shareable Model Template Types
1567
+
1568
+ Use `typeof` to create reusable model template types that can be shared across your schema:
1569
+
1570
+ ```typescript
1571
+ // -- @test/schema/models/user.model.ts --
1572
+ import { model } from 'miragejs-orm';
1573
+ import type { User } from '@domain/users/types';
1574
+
1575
+ // Define user model attributes type
1576
+ export type UserAttrs = { name: string; email: string; role: string };
1577
+ // Define user model output type to be produced during serialization
1578
+ export type UserJSON = { user: User; current?: boolean };
1579
+
1580
+ // Create user model template
1581
+ export const userModel = model('user', 'users')
1582
+ .attrs<UserAttrs>()
1583
+ .json<UserJSON, User[]>()
1584
+ .build();
1585
+
1586
+ // Define a shareable user model type
1587
+ export type UserModel = typeof userModel;
1588
+ ```
1589
+
1590
+ ```typescript
1591
+ // -- @test/schema/models/post.model.ts --
1592
+ import { model } from 'miragejs-orm';
1593
+ import type { Post } from '@domain/posts/types';
1594
+
1595
+ // Define post attributes type
1596
+ export type PostAttrs = { title: string; content: string; authorId: string };
1597
+
1598
+ // Create post model template
1599
+ export const postModel = model('post', 'posts')
1600
+ .attrs<PostAttrs>()
1601
+ .json<Post, Post[]>() // Use existing Post entity type without transformations
1602
+ .build();
1603
+
1604
+ // Define shareable post model type
1605
+ export type PostModel = typeof postModel;
1606
+ ```
1607
+
1608
+ ```typescript
1609
+ // -- @test/schema/collections/user.collection.ts --
1610
+ // Use shareable model types in your collections
1611
+ import {
1612
+ userModel,
1613
+ type UserModel,
1614
+ postModel,
1615
+ type PostModel,
1616
+ } from '@test/schema/models';
1617
+ ```
1618
+
1619
+ ### Typing Schema
1620
+
1621
+ Define explicit schema types for use across your application (e.g. `TestSchema` / `TestCollections` for a test schema):
1622
+
1623
+ ```typescript
1624
+ // -- @test/schema/schema.ts or types.ts --
1625
+ import { schema, collection, relations } from 'miragejs-orm';
1626
+ import type {
1627
+ CollectionConfig,
1628
+ HasMany,
1629
+ BelongsTo,
1630
+ SchemaInstance,
1631
+ Factory,
1632
+ } from 'miragejs-orm';
1633
+ import type { UserModel, PostModel } from './models';
1634
+
1635
+ // Define your schema collections type
1636
+ export type TestCollections = {
1637
+ users: CollectionConfig<
1638
+ UserModel,
1639
+ { posts: HasMany<PostModel> },
1640
+ Factory<UserModel, 'admin' | 'verified', TestCollections>,
1641
+ TestCollections
1642
+ >;
1643
+ posts: CollectionConfig<
1644
+ PostModel,
1645
+ { author: BelongsTo<UserModel, 'authorId'> },
1646
+ Factory<PostModel, 'published', TestCollections>,
1647
+ TestCollections
1648
+ >;
1649
+ };
1650
+
1651
+ export type TestSchema = SchemaInstance<TestCollections>;
1652
+ ```
1653
+
1654
+ ### Typing Model Instances
1655
+
1656
+ Use the `ModelInstance` type to properly type materialized model instances with full relationship support:
1657
+
1658
+ ```typescript
1659
+ import type { ModelInstance } from 'miragejs-orm';
1660
+ import type { UserModel } from '@test/schema/models';
1661
+ import type { TestCollections } from '@test/schema/types';
1662
+
1663
+ // Type a user model instance
1664
+ type UserInstance = ModelInstance<UserModel, TestCollections>;
1665
+
1666
+ // Usage in functions or variable assignments
1667
+ function processUser(user: UserInstance) {
1668
+ // Full type safety for attributes
1669
+ console.log(user.name); // ✅ string
1670
+ console.log(user.email); // ✅ string
1671
+ console.log(user.role); // ✅ string
1672
+
1673
+ // Full type safety for relationships
1674
+ console.log(user.posts); // ✅ ModelCollection<PostModel>
1675
+ user.posts.forEach((post) => {
1676
+ console.log(post.title); // ✅ Fully typed
1677
+ });
1678
+
1679
+ // Full type safety for methods
1680
+ user.update({ name: 'New Name' }); // ✅ Type-safe attributes
1681
+ user.save(); // ✅ Method available
1682
+ user.destroy(); // ✅ Method available
1683
+ }
1684
+ ```
1685
+
1686
+ **How Type Inference Works:**
1687
+
1688
+ The `ModelInstance<TTemplate, TSchema>` type uses the schema to construct the complete model type:
1689
+
1690
+ 1. **Attributes** - Extracted from the model template's `attrs` type
1691
+ 2. **Relationships** - Looked up from the schema's collection configuration
1692
+ 3. **Foreign Keys** - Automatically inferred from relationship definitions
1693
+ 4. **Methods** - Inherited from the base `Model` class (`.save()`, `.update()`, `.destroy()`, `.reload()`, `.link()`, `.unlink()`, `.related()`)
1694
+ 5. **Accessors** - Both attribute accessors and relationship accessors are fully typed
1695
+
1696
+ ### Typing Collections
1697
+
1698
+ Pass schema type to collections for type-safe `schema` usage and data validation (e.g., seeds, fixtures, etc.):
1699
+
1700
+ ```typescript
1701
+ // -- @test/schema/collections/user.collection.ts --
1702
+ import { collection, relations } from 'miragejs-orm';
1703
+ import { userModel, postModel } from '@test/schema/models';
1704
+ import type { TestCollections } from '@test/schema/types';
1705
+
1706
+ // Create user collection
1707
+ const userCollection = collection<TestCollections>()
1708
+ .model(userModel)
1709
+ .relationships({
1710
+ posts: relations.hasMany(postModel),
1711
+ })
1712
+ .seeds({
1713
+ testUsers: (schema) => {
1714
+ schema.users.create({ name: 'John', email: 'john@example.com' });
1715
+ schema.users.create({ name: 'Jane', email: 'jane@example.com' });
1716
+ },
1717
+ })
1718
+ .build();
1719
+ ```
1720
+
1721
+ ### Typing Factories
1722
+
1723
+ Pass schema type to factories for type-safe `associations` and `afterCreate` hooks. Factory type is `Factory<TModel, TTraits, TSchema>` where `TTraits` is the union of trait names:
1724
+
1725
+ ```typescript
1726
+ import { factory, associations } from 'miragejs-orm';
1727
+ import { userModel, postModel } from '@test/schema/models';
1728
+ import type { TestCollections } from '@test/schema/types';
1729
+
1730
+ export const postFactory = factory<TestCollections>() // IDE suggests target model relationships and trait names
1731
+ .model(postModel)
1732
+ .attrs({
1733
+ title: () => 'Sample Post',
1734
+ content: () => 'Content here',
1735
+ })
1736
+ .associations({
1737
+ posts: associations.createMany<PostModel, TestCollections>( // IDE suggests related model attributes and trait names
1738
+ postModel,
1739
+ 3,
1740
+ 'published',
1741
+ ),
1742
+ })
1743
+ .afterCreate((post, schema) => {
1744
+ // schema is fully typed! IDE autocomplete works perfectly
1745
+ const user = schema.users.first();
1746
+ if (user) {
1747
+ post.update({ author: user });
1748
+ }
1749
+ })
1750
+ .build();
1751
+ ```
1752
+
1753
+ **Key Benefits:**
1754
+
1755
+ - ✅ Full IDE autocomplete and IntelliSense
1756
+ - ✅ Type-safe relationship definitions
1757
+ - ✅ Catch errors at compile time
1758
+ - ✅ Refactor with confidence
1759
+
1760
+ ---
1761
+
1762
+ ## 📖 API Reference
1763
+
1764
+ **Full Documentation Website Coming Soon!** 🚀
1765
+
1766
+ We're working on a comprehensive documentation website with detailed API references, interactive examples, and guides. Stay tuned!
1767
+
1768
+ In the meantime:
1769
+
1770
+ - **TypeScript Definitions**: See the [TypeScript definitions](./lib/dist/index.d.ts) for complete API signatures
1771
+ - **IDE Autocomplete**: The library is fully typed — your IDE will provide inline documentation and type hints
1772
+ - **This README**: Contains extensive examples covering most use cases
1773
+
1774
+ ---
1775
+
1776
+ ## 🤝 Contributing
1777
+
1778
+ We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details.
1779
+
1780
+ ---
1781
+
1782
+ ## 📄 License
1783
+
1784
+ MIT © [MirageJS](LICENSE)
1785
+
1786
+ ---
1787
+
1788
+ **Built with ❤️ for frontend developers who want to move fast without breaking things.**