@stonyx/orm 0.2.1-beta.2 → 0.2.1-beta.3

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,7 +1,7 @@
1
1
  # @stonyx/orm
2
2
 
3
3
  A lightweight ORM for Stonyx projects, featuring model definitions, serializers, relationships, transforms, and optional REST server integration.
4
- `@stonyx/orm` provides a structured way to define models, manage relationships, and persist data in JSON files. It also allows integration with the Stonyx REST server for automatic route setup and access control.
4
+ `@stonyx/orm` provides a structured way to define models, manage relationships, and persist data in JSON files or MySQL. It also allows integration with the Stonyx REST server for automatic route setup and access control.
5
5
 
6
6
  ## Highlights
7
7
 
@@ -9,8 +9,9 @@ A lightweight ORM for Stonyx projects, featuring model definitions, serializers,
9
9
  - **Models**: Define attributes with type-safe proxies (`attr`) and relationships (`hasMany`, `belongsTo`).
10
10
  - **Serializers**: Map raw data into model-friendly structures, including nested properties.
11
11
  - **Transforms**: Apply custom transformations on data values automatically.
12
- - **DB Integration**: Optional file-based persistence with auto-save support.
12
+ - **DB Integration**: Optional file-based persistence with auto-save support, or MySQL for production workloads.
13
13
  - **REST Server Integration**: Automatic route setup with customizable access control.
14
+ - **Lifecycle Hooks**: Middleware-based before/after hooks for validation, authorization, side effects, and auditing.
14
15
 
15
16
  ## Installation
16
17
 
@@ -32,9 +33,18 @@ const {
32
33
  ORM_USE_REST_SERVER,
33
34
  DB_AUTO_SAVE,
34
35
  DB_FILE,
36
+ DB_MODE,
37
+ DB_DIRECTORY,
35
38
  DB_SCHEMA_PATH,
36
- DB_SAVE_INTERVAL
37
- } = process;
39
+ DB_SAVE_INTERVAL,
40
+ MYSQL_HOST,
41
+ MYSQL_PORT,
42
+ MYSQL_USER,
43
+ MYSQL_PASSWORD,
44
+ MYSQL_DATABASE,
45
+ MYSQL_CONNECTION_LIMIT,
46
+ MYSQL_MIGRATIONS_DIR,
47
+ } = process.env;
38
48
 
39
49
  export default {
40
50
  orm: {
@@ -44,6 +54,8 @@ export default {
44
54
  db: {
45
55
  autosave: DB_AUTO_SAVE ?? 'false',
46
56
  file: DB_FILE ?? 'db.json',
57
+ mode: DB_MODE ?? 'file', // 'file' (single db.json) or 'directory' (one file per collection)
58
+ directory: DB_DIRECTORY ?? 'db', // directory name for collection files when mode is 'directory'
47
59
  saveInterval: DB_SAVE_INTERVAL ?? 3600, // 1 hour
48
60
  schema: DB_SCHEMA_PATH ?? './config/db-schema.js'
49
61
  },
@@ -53,6 +65,16 @@ export default {
53
65
  serializer: ORM_SERIALIZER_PATH ?? './serializers',
54
66
  transform: ORM_TRANSFORM_PATH ?? './transforms'
55
67
  },
68
+ mysql: MYSQL_HOST ? {
69
+ host: MYSQL_HOST ?? 'localhost',
70
+ port: parseInt(MYSQL_PORT ?? '3306'),
71
+ user: MYSQL_USER ?? 'root',
72
+ password: MYSQL_PASSWORD ?? '',
73
+ database: MYSQL_DATABASE ?? 'stonyx',
74
+ connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
75
+ migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
76
+ migrationsTable: '__migrations',
77
+ } : undefined,
56
78
  restServer: {
57
79
  enabled: ORM_USE_REST_SERVER ?? 'true',
58
80
  route: ORM_REST_ROUTE ?? '/'
@@ -61,16 +83,13 @@ export default {
61
83
  };
62
84
  ```
63
85
 
64
- Then initialize the Stonyx framework, which auto-initializes all of its modules, including `@stonyx/rest-server`:
86
+ Then run the application via the Stonyx CLI, which auto-initializes all modules including the ORM:
65
87
 
66
- ```js
67
- import Stonyx from 'stonyx';
68
- import config from './config/environment.js';
69
-
70
- new Stonyx(config);
88
+ ```bash
89
+ stonyx serve
71
90
  ```
72
91
 
73
- For further framework initialization instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
92
+ For further framework instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
74
93
 
75
94
  ## Models
76
95
 
@@ -154,7 +173,7 @@ export default function(value) {
154
173
 
155
174
  ## Database (DB) Integration
156
175
 
157
- The ORM can automatically save records to a JSON file with optional auto-save intervals.
176
+ The ORM can automatically save records to a JSON file or a directory of collection files, with optional auto-save intervals.
158
177
 
159
178
  ```js
160
179
  import Orm from '@stonyx/orm';
@@ -168,11 +187,26 @@ const dbRecord = Orm.db;
168
187
 
169
188
  Configuration options are in `config/environment.js`:
170
189
 
171
- * `DB_AUTO_SAVE`: Whether to auto-save.
190
+ * `DB_AUTO_SAVE`: Auto-save mode — `'true'` (cron-based interval), `'false'` (disabled), or `'onUpdate'` (save after every create/update/delete via REST API).
172
191
  * `DB_FILE`: File path to store data.
173
- * `DB_SAVE_INTERVAL`: Interval in seconds for auto-save.
192
+ * `DB_MODE`: Storage mode — `'file'` (single JSON file, default) or `'directory'` (one file per collection in a directory).
193
+ * `DB_DIRECTORY`: Directory name for collection files when mode is `'directory'` (default: `'db'`).
194
+ * `DB_SAVE_INTERVAL`: Interval in seconds for auto-save (only applies when `DB_AUTO_SAVE` is `'true'`).
174
195
  * `DB_SCHEMA_PATH`: Path to DB schema.
175
196
 
197
+ In directory mode, each collection is stored as `{directory}/{collection}.json` (e.g., `db/animals.json`, `db/owners.json`). The main `db.json` is kept as a skeleton with empty arrays. Migration commands are available: `stonyx db:migrate-to-directory` and `stonyx db:migrate-to-file`.
198
+
199
+ ### MySQL Mode
200
+
201
+ Set the `MYSQL_HOST` environment variable to enable MySQL persistence. The ORM loads all records into memory on startup and persists CRUD operations to MySQL automatically. Supports schema-aware migration generation, apply, rollback, and drift detection.
202
+
203
+ | Command | Description |
204
+ |---------|-------------|
205
+ | `stonyx db:generate-migration <desc>` | Generate a migration from model schema diffs |
206
+ | `stonyx db:migrate` | Apply pending migrations |
207
+ | `stonyx db:migrate:rollback` | Rollback the most recent migration |
208
+ | `stonyx db:migrate:status` | Show migration status |
209
+
176
210
  ## REST Server Integration
177
211
 
178
212
  The ORM can automatically register REST routes using your access classes.
@@ -289,6 +323,372 @@ GET /animals/1
289
323
 
290
324
  - Only available on GET endpoints (not POST/PATCH)
291
325
 
326
+ ## Lifecycle Hooks
327
+
328
+ The ORM provides a powerful middleware-based hook system that allows you to run custom logic before and after CRUD operations. Hooks are perfect for validation, transformation, side effects, authorization, and auditing.
329
+
330
+ ### Overview
331
+
332
+ Hooks run at key points in the request lifecycle:
333
+
334
+ - **Before hooks**: Run before the operation executes. **Can halt operations** by returning a value (status code or response object).
335
+ - **After hooks**: Run after the operation completes (logging, notifications, cache invalidation).
336
+
337
+ ### Event Naming Convention
338
+
339
+ Events follow the pattern: `{timing}:{operation}:{modelName}`
340
+
341
+ **Operations:**
342
+ - `list` - GET collection (`/animals`)
343
+ - `get` - GET single record (`/animals/1`)
344
+ - `create` - POST new record (`/animals`)
345
+ - `update` - PATCH existing record (`/animals/1`)
346
+ - `delete` - DELETE record (`/animals/1`)
347
+
348
+ **Examples:**
349
+ - `before:create:animal` - Before creating an animal
350
+ - `after:list:owner` - After fetching owner collection
351
+ - `before:update:trait` - Before updating a trait
352
+
353
+ ### Hook Context Object
354
+
355
+ Each hook receives a context object with comprehensive information:
356
+
357
+ ```javascript
358
+ {
359
+ model: 'animal', // Model name
360
+ operation: 'create', // Operation type
361
+ request, // Express request object
362
+ params, // URL params (e.g., { id: 5 })
363
+ body, // Request body (POST/PATCH)
364
+ query, // Query parameters
365
+ state, // Request state object
366
+ record, // Record instance (after hooks, single operations)
367
+ records, // Record array (after hooks, list operations)
368
+ response, // Response data (after hooks)
369
+ oldState, // Previous record state (update/delete operations only)
370
+ recordId, // Record ID (delete operations in after hooks)
371
+ }
372
+ ```
373
+
374
+ **Important Notes**:
375
+ - `oldState` is only available for `update` and `delete` operations
376
+ - It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
377
+ - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
378
+ - For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
379
+ - `oldState` is captured from `record.__data` or the record itself, providing access to the raw data structure
380
+
381
+ ### Usage Examples
382
+
383
+ #### Basic Hook Registration
384
+
385
+ ```javascript
386
+ import { beforeHook, afterHook } from '@stonyx/orm';
387
+
388
+ // Validation before creating - can halt by returning a value
389
+ beforeHook('create', 'animal', (context) => {
390
+ const { age } = context.body.data.attributes;
391
+ if (age < 0) {
392
+ return 400; // Halt with 400 Bad Request
393
+ }
394
+ // Return undefined to continue
395
+ });
396
+
397
+ // Logging after updates
398
+ afterHook('update', 'animal', (context) => {
399
+ console.log(`Animal ${context.record.id} was updated`);
400
+ });
401
+ ```
402
+
403
+ #### Halting Operations
404
+
405
+ Before hooks can halt operations by returning a value:
406
+
407
+ ```javascript
408
+ import { beforeHook } from '@stonyx/orm';
409
+
410
+ // Return a status code to halt with that HTTP status
411
+ beforeHook('create', 'animal', (context) => {
412
+ if (!context.body.data.attributes.name) {
413
+ return 400; // Bad Request
414
+ }
415
+ });
416
+
417
+ // Return an object to send a custom response
418
+ beforeHook('delete', 'animal', (context) => {
419
+ const animal = store.get('animal', context.params.id);
420
+ if (animal.protected) {
421
+ return { errors: [{ detail: 'Cannot delete protected animals' }] };
422
+ }
423
+ });
424
+
425
+ // Return undefined (or nothing) to allow operation to continue
426
+ beforeHook('update', 'animal', (context) => {
427
+ console.log('Update proceeding...');
428
+ // No return = operation continues
429
+ });
430
+ ```
431
+
432
+ #### Data Transformation
433
+
434
+ ```javascript
435
+ // Normalize data before saving
436
+ beforeHook('create', 'owner', (context) => {
437
+ const attrs = context.body.data.attributes;
438
+ if (attrs.email) {
439
+ attrs.email = attrs.email.toLowerCase().trim();
440
+ }
441
+ });
442
+ ```
443
+
444
+ #### Side Effects
445
+
446
+ ```javascript
447
+ // Send notification after animal is adopted (using oldState to detect changes)
448
+ afterHook('update', 'animal', async (context) => {
449
+ // Use oldState to compare before/after values
450
+ if (context.oldState && context.oldState.owner !== context.record.owner) {
451
+ await sendNotification({
452
+ type: 'adoption',
453
+ animalId: context.record.id,
454
+ previousOwner: context.oldState.owner,
455
+ newOwner: context.record.owner
456
+ });
457
+ }
458
+ });
459
+
460
+ // Cache invalidation
461
+ afterHook('delete', 'animal', async (context) => {
462
+ await cache.invalidate(`owner:${context.params.id}:pets`);
463
+ });
464
+ ```
465
+
466
+ #### Change Detection
467
+
468
+ The `oldState` property (available for `update` and `delete` operations) enables precise change tracking:
469
+
470
+ ```javascript
471
+ // Detect specific field changes
472
+ afterHook('update', 'animal', async (context) => {
473
+ if (!context.oldState) return; // No old state for create operations
474
+
475
+ // Check if a specific field changed
476
+ if (context.oldState.age !== context.record.age) {
477
+ console.log(`Age changed from ${context.oldState.age} to ${context.record.age}`);
478
+ }
479
+
480
+ // Track multiple field changes
481
+ const changedFields = [];
482
+ for (const key in context.record.__data) {
483
+ if (context.oldState[key] !== context.record.__data[key]) {
484
+ changedFields.push(key);
485
+ }
486
+ }
487
+
488
+ if (changedFields.length > 0) {
489
+ console.log(`Fields changed: ${changedFields.join(', ')}`);
490
+ }
491
+ });
492
+
493
+ // Access deleted record data
494
+ afterHook('delete', 'animal', async (context) => {
495
+ console.log(`Deleted animal: ${context.oldState.type} (age: ${context.oldState.age})`);
496
+ // oldState contains full snapshot of the deleted record
497
+ });
498
+ ```
499
+
500
+ #### Authorization
501
+
502
+ ```javascript
503
+ // Additional access control - halt with 403 if unauthorized
504
+ beforeHook('delete', 'animal', (context) => {
505
+ const user = context.state.currentUser;
506
+ const animal = store.get('animal', context.params.id);
507
+
508
+ if (animal.owner !== user.id && !user.isAdmin) {
509
+ return 403; // Forbidden
510
+ }
511
+ });
512
+ ```
513
+
514
+ #### Auditing
515
+
516
+ ```javascript
517
+ // Audit log for all changes with field-level change tracking
518
+ afterHook('update', 'animal', async (context) => {
519
+ // Compare oldState with current record to capture exact changes
520
+ const changes = {};
521
+ if (context.oldState) {
522
+ for (const [key, newValue] of Object.entries(context.record.__data || context.record)) {
523
+ if (context.oldState[key] !== newValue) {
524
+ changes[key] = { from: context.oldState[key], to: newValue };
525
+ }
526
+ }
527
+ }
528
+
529
+ await auditLog.create({
530
+ operation: 'update',
531
+ model: context.model,
532
+ recordId: context.record.id,
533
+ userId: context.state.currentUser?.id,
534
+ timestamp: new Date(),
535
+ changes // Precise field-level changes: { age: { from: 2, to: 3 } }
536
+ });
537
+ });
538
+
539
+ // Audit deletes with full record snapshot
540
+ afterHook('delete', 'animal', async (context) => {
541
+ await auditLog.create({
542
+ operation: 'delete',
543
+ model: context.model,
544
+ recordId: context.recordId,
545
+ userId: context.state.currentUser?.id,
546
+ timestamp: new Date(),
547
+ deletedData: context.oldState // Full snapshot of deleted record
548
+ });
549
+ });
550
+ ```
551
+
552
+ #### Error Handling
553
+
554
+ For after hooks, wrap in try/catch if errors should not propagate:
555
+
556
+ ```javascript
557
+ afterHook('create', 'animal', async (context) => {
558
+ try {
559
+ await sendWelcomeEmail(context.record.owner);
560
+ } catch (error) {
561
+ // Error is logged but doesn't fail the create operation
562
+ console.error('Failed to send welcome email:', error);
563
+ }
564
+ });
565
+ ```
566
+
567
+ ### Hook Lifecycle Management
568
+
569
+ #### Unsubscribing
570
+
571
+ ```javascript
572
+ import { beforeHook } from '@stonyx/orm';
573
+
574
+ // Get unsubscribe function
575
+ const unsubscribe = beforeHook('create', 'animal', handler);
576
+
577
+ // Later, remove the hook
578
+ unsubscribe();
579
+ ```
580
+
581
+ #### Clearing Hooks
582
+
583
+ ```javascript
584
+ import { clearHook, clearAllHooks } from '@stonyx/orm';
585
+
586
+ // Remove all hooks for a specific operation:model
587
+ clearHook('create', 'animal');
588
+
589
+ // Remove only before hooks
590
+ clearHook('create', 'animal', 'before');
591
+
592
+ // Remove only after hooks
593
+ clearHook('create', 'animal', 'after');
594
+
595
+ // Remove ALL hooks (useful for testing)
596
+ clearAllHooks();
597
+ ```
598
+
599
+ ### Advanced Patterns
600
+
601
+ #### Conditional Hooks
602
+
603
+ ```javascript
604
+ beforeHook('update', 'animal', (context) => {
605
+ // Only validate if age is being updated
606
+ if ('age' in context.body.data.attributes) {
607
+ const { age } = context.body.data.attributes;
608
+ if (age < 0 || age > 50) {
609
+ return 400; // Bad Request
610
+ }
611
+ }
612
+ });
613
+ ```
614
+
615
+ #### Cross-Model Hooks
616
+
617
+ ```javascript
618
+ // Update owner's pet count when animal is created
619
+ afterHook('create', 'animal', async (context) => {
620
+ const owner = store.get('owner', context.record.owner);
621
+ if (owner) {
622
+ owner.petCount = (owner.petCount || 0) + 1;
623
+ }
624
+ });
625
+ ```
626
+
627
+ #### Sequential Middleware
628
+
629
+ ```javascript
630
+ // Multiple hooks run in registration order
631
+ beforeHook('create', 'post', (context) => {
632
+ console.log('First middleware');
633
+ context.customData = { checked: true };
634
+ });
635
+
636
+ beforeHook('create', 'post', (context) => {
637
+ console.log('Second middleware');
638
+ // Can access data from previous hooks
639
+ if (!context.customData?.checked) {
640
+ return 403;
641
+ }
642
+ });
643
+ ```
644
+
645
+ ### Hook Execution Order
646
+
647
+ 1. **Before hooks** fire first (sequentially, in registration order)
648
+ 2. **Main operation** executes (if no before hook halted)
649
+ 3. **After hooks** fire last (sequentially, in registration order)
650
+
651
+ Before hooks can halt the operation by returning a value. After hooks run after completion and cannot halt.
652
+
653
+ ### Best Practices
654
+
655
+ 1. **Keep hooks focused**: Each hook should do one thing well
656
+ 2. **Use async/await**: Hooks support async functions for consistency
657
+ 3. **Return values intentionally**: Only return a value from before hooks when you want to halt
658
+ 4. **Document side effects**: Make it clear what each hook does
659
+ 5. **Test hooks independently**: Write unit tests for hook logic
660
+ 6. **Avoid heavy operations**: Keep hooks fast to maintain performance
661
+ 7. **Clean up in tests**: Use `clearAllHooks()` in test teardown
662
+
663
+ ### Testing Hooks
664
+
665
+ ```javascript
666
+ import { beforeHook, clearAllHooks } from '@stonyx/orm';
667
+
668
+ // Clean up after each test
669
+ afterEach(() => {
670
+ clearAllHooks();
671
+ });
672
+
673
+ // Test that validation hook halts with 400
674
+ test('validation hook rejects negative age', async () => {
675
+ beforeHook('create', 'animal', (context) => {
676
+ if (context.body.data.attributes.age < 0) {
677
+ return 400;
678
+ }
679
+ });
680
+
681
+ const response = await fetch('/animals', {
682
+ method: 'POST',
683
+ body: JSON.stringify({
684
+ data: { attributes: { age: -5 } }
685
+ })
686
+ });
687
+
688
+ assert.strictEqual(response.status, 400, 'Hook halted with 400');
689
+ });
690
+ ```
691
+
292
692
  ## Exported Helpers
293
693
 
294
694
  | Export | Description |
@@ -297,9 +697,18 @@ GET /animals/1
297
697
  | `belongsTo` | Define a one-to-one relationship. |
298
698
  | `hasMany` | Define a one-to-many relationship. |
299
699
  | `createRecord` | Instantiate a record with proper serialization and relationships. |
700
+ | `updateRecord` | Update an existing record with new data. |
300
701
  | `store` | Singleton store for all model instances. |
301
702
  | `relationships` | Access all relationships (`hasMany`, `belongsTo`, `global`, `pending`). |
703
+ | `beforeHook` | Register a before hook that can halt operations. |
704
+ | `afterHook` | Register an after hook for post-operation logic. |
705
+ | `clearHook` | Clear hooks for a specific operation:model. |
706
+ | `clearAllHooks` | Clear all registered hooks (useful for testing). |
707
+
708
+ ## Project Structure
709
+
710
+ For a full architectural reference, see [project-structure.md](project-structure.md).
302
711
 
303
712
  ## License
304
713
 
305
- Apache — do what you want, just keep attribution.
714
+ Apache 2.0 see [LICENSE.md](LICENSE.md).
@@ -7,17 +7,28 @@ const {
7
7
  ORM_USE_REST_SERVER,
8
8
  DB_AUTO_SAVE,
9
9
  DB_FILE,
10
+ DB_MODE,
11
+ DB_DIRECTORY,
10
12
  DB_SCHEMA_PATH,
11
- DB_SAVE_INTERVAL
12
- } = process;
13
+ DB_SAVE_INTERVAL,
14
+ MYSQL_HOST,
15
+ MYSQL_PORT,
16
+ MYSQL_USER,
17
+ MYSQL_PASSWORD,
18
+ MYSQL_DATABASE,
19
+ MYSQL_CONNECTION_LIMIT,
20
+ MYSQL_MIGRATIONS_DIR,
21
+ } = process.env;
13
22
 
14
23
  export default {
15
24
  logColor: 'white',
16
25
  logMethod: 'db',
17
26
 
18
27
  db: {
19
- autosave: DB_AUTO_SAVE ?? 'false',
28
+ autosave: DB_AUTO_SAVE ?? 'false', // 'true' (cron interval), 'false' (disabled), 'onUpdate' (save after each write op)
20
29
  file: DB_FILE ?? 'db.json',
30
+ mode: DB_MODE ?? 'file', // 'file' (single db.json) or 'directory' (one file per collection)
31
+ directory: DB_DIRECTORY ?? 'db', // directory name for collection files when mode is 'directory'
21
32
  saveInterval: DB_SAVE_INTERVAL ?? 60 * 60, // 1 hour
22
33
  schema: DB_SCHEMA_PATH ?? './config/db-schema.js'
23
34
  },
@@ -27,8 +38,18 @@ export default {
27
38
  serializer: ORM_SERIALIZER_PATH ?? './serializers',
28
39
  transform: ORM_TRANSFORM_PATH ?? './transforms'
29
40
  },
41
+ mysql: MYSQL_HOST ? {
42
+ host: MYSQL_HOST ?? 'localhost',
43
+ port: parseInt(MYSQL_PORT ?? '3306'),
44
+ user: MYSQL_USER ?? 'root',
45
+ password: MYSQL_PASSWORD ?? '',
46
+ database: MYSQL_DATABASE ?? 'stonyx',
47
+ connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
48
+ migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
49
+ migrationsTable: '__migrations',
50
+ } : undefined,
30
51
  restServer: {
31
- enabled: ORM_USE_REST_SERVER ?? 'true', // Whether to load restServer for automatic route setup or
52
+ enabled: ORM_USE_REST_SERVER ?? 'true', // Whether to load restServer for automatic route setup or
32
53
  route: ORM_REST_ROUTE ?? '/',
33
54
  }
34
- }
55
+ }