@stonyx/orm 0.2.1-beta.2 → 0.2.1-beta.21
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/.claude/code-style-rules.md +44 -0
- package/.claude/hooks.md +250 -0
- package/.claude/index.md +281 -0
- package/.claude/usage-patterns.md +234 -0
- package/.github/workflows/publish.yml +17 -1
- package/README.md +440 -15
- package/config/environment.js +26 -5
- package/improvements.md +139 -0
- package/package.json +19 -8
- package/project-structure.md +343 -0
- package/src/commands.js +170 -0
- package/src/db.js +132 -6
- package/src/hooks.js +124 -0
- package/src/index.js +8 -1
- package/src/main.js +47 -3
- package/src/manage-record.js +19 -4
- package/src/migrate.js +72 -0
- package/src/model.js +11 -0
- package/src/mysql/connection.js +28 -0
- package/src/mysql/migration-generator.js +188 -0
- package/src/mysql/migration-runner.js +110 -0
- package/src/mysql/mysql-db.js +422 -0
- package/src/mysql/query-builder.js +64 -0
- package/src/mysql/schema-introspector.js +160 -0
- package/src/mysql/type-map.js +37 -0
- package/src/orm-request.js +307 -53
- package/src/plural-registry.js +12 -0
- package/src/record.js +35 -8
- package/src/serializer.js +2 -2
- package/src/setup-rest-server.js +4 -1
- package/src/store.js +105 -0
- package/src/utils.js +12 -0
- package/test-events-setup.js +41 -0
- package/test-hooks-manual.js +54 -0
- package/test-hooks-with-logging.js +52 -0
- package/.claude/project-structure.md +0 -578
- package/stonyx-bootstrap.cjs +0 -30
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
|
-
|
|
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
|
|
86
|
+
Then run the application via the Stonyx CLI, which auto-initializes all modules including the ORM:
|
|
65
87
|
|
|
66
|
-
```
|
|
67
|
-
|
|
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
|
|
92
|
+
For further framework instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
|
|
74
93
|
|
|
75
94
|
## Models
|
|
76
95
|
|
|
@@ -90,6 +109,22 @@ export default class OwnerModel extends Model {
|
|
|
90
109
|
}
|
|
91
110
|
```
|
|
92
111
|
|
|
112
|
+
### Overriding Plural Names
|
|
113
|
+
|
|
114
|
+
By default, model names are auto-pluralized for REST routes, JSON:API URLs, and DB table names (e.g., `animal` → `animals`). When auto-pluralization produces the wrong result, override it with `static pluralName`:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
import { Model, attr } from '@stonyx/orm';
|
|
118
|
+
|
|
119
|
+
export default class PersonModel extends Model {
|
|
120
|
+
static pluralName = 'people';
|
|
121
|
+
|
|
122
|
+
name = attr('string');
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The override is picked up automatically during ORM initialization. All routes, JSON:API type references, and MySQL table names will use the overridden value.
|
|
127
|
+
|
|
93
128
|
## Serializers
|
|
94
129
|
|
|
95
130
|
Based on the following sample payload structure which represents a poorly structure third-party data source:
|
|
@@ -154,7 +189,7 @@ export default function(value) {
|
|
|
154
189
|
|
|
155
190
|
## Database (DB) Integration
|
|
156
191
|
|
|
157
|
-
The ORM can automatically save records to a JSON file with optional auto-save intervals.
|
|
192
|
+
The ORM can automatically save records to a JSON file or a directory of collection files, with optional auto-save intervals.
|
|
158
193
|
|
|
159
194
|
```js
|
|
160
195
|
import Orm from '@stonyx/orm';
|
|
@@ -168,11 +203,26 @@ const dbRecord = Orm.db;
|
|
|
168
203
|
|
|
169
204
|
Configuration options are in `config/environment.js`:
|
|
170
205
|
|
|
171
|
-
* `DB_AUTO_SAVE`:
|
|
206
|
+
* `DB_AUTO_SAVE`: Auto-save mode — `'true'` (cron-based interval), `'false'` (disabled), or `'onUpdate'` (save after every create/update/delete via REST API).
|
|
172
207
|
* `DB_FILE`: File path to store data.
|
|
173
|
-
* `
|
|
208
|
+
* `DB_MODE`: Storage mode — `'file'` (single JSON file, default) or `'directory'` (one file per collection in a directory).
|
|
209
|
+
* `DB_DIRECTORY`: Directory name for collection files when mode is `'directory'` (default: `'db'`).
|
|
210
|
+
* `DB_SAVE_INTERVAL`: Interval in seconds for auto-save (only applies when `DB_AUTO_SAVE` is `'true'`).
|
|
174
211
|
* `DB_SCHEMA_PATH`: Path to DB schema.
|
|
175
212
|
|
|
213
|
+
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`.
|
|
214
|
+
|
|
215
|
+
### MySQL Mode
|
|
216
|
+
|
|
217
|
+
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.
|
|
218
|
+
|
|
219
|
+
| Command | Description |
|
|
220
|
+
|---------|-------------|
|
|
221
|
+
| `stonyx db:generate-migration <desc>` | Generate a migration from model schema diffs |
|
|
222
|
+
| `stonyx db:migrate` | Apply pending migrations |
|
|
223
|
+
| `stonyx db:migrate:rollback` | Rollback the most recent migration |
|
|
224
|
+
| `stonyx db:migrate:status` | Show migration status |
|
|
225
|
+
|
|
176
226
|
## REST Server Integration
|
|
177
227
|
|
|
178
228
|
The ORM can automatically register REST routes using your access classes.
|
|
@@ -289,6 +339,372 @@ GET /animals/1
|
|
|
289
339
|
|
|
290
340
|
- Only available on GET endpoints (not POST/PATCH)
|
|
291
341
|
|
|
342
|
+
## Lifecycle Hooks
|
|
343
|
+
|
|
344
|
+
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.
|
|
345
|
+
|
|
346
|
+
### Overview
|
|
347
|
+
|
|
348
|
+
Hooks run at key points in the request lifecycle:
|
|
349
|
+
|
|
350
|
+
- **Before hooks**: Run before the operation executes. **Can halt operations** by returning a value (status code or response object).
|
|
351
|
+
- **After hooks**: Run after the operation completes (logging, notifications, cache invalidation).
|
|
352
|
+
|
|
353
|
+
### Event Naming Convention
|
|
354
|
+
|
|
355
|
+
Events follow the pattern: `{timing}:{operation}:{modelName}`
|
|
356
|
+
|
|
357
|
+
**Operations:**
|
|
358
|
+
- `list` - GET collection (`/animals`)
|
|
359
|
+
- `get` - GET single record (`/animals/1`)
|
|
360
|
+
- `create` - POST new record (`/animals`)
|
|
361
|
+
- `update` - PATCH existing record (`/animals/1`)
|
|
362
|
+
- `delete` - DELETE record (`/animals/1`)
|
|
363
|
+
|
|
364
|
+
**Examples:**
|
|
365
|
+
- `before:create:animal` - Before creating an animal
|
|
366
|
+
- `after:list:owner` - After fetching owner collection
|
|
367
|
+
- `before:update:trait` - Before updating a trait
|
|
368
|
+
|
|
369
|
+
### Hook Context Object
|
|
370
|
+
|
|
371
|
+
Each hook receives a context object with comprehensive information:
|
|
372
|
+
|
|
373
|
+
```javascript
|
|
374
|
+
{
|
|
375
|
+
model: 'animal', // Model name
|
|
376
|
+
operation: 'create', // Operation type
|
|
377
|
+
request, // Express request object
|
|
378
|
+
params, // URL params (e.g., { id: 5 })
|
|
379
|
+
body, // Request body (POST/PATCH)
|
|
380
|
+
query, // Query parameters
|
|
381
|
+
state, // Request state object
|
|
382
|
+
record, // Record instance (after hooks, single operations)
|
|
383
|
+
records, // Record array (after hooks, list operations)
|
|
384
|
+
response, // Response data (after hooks)
|
|
385
|
+
oldState, // Previous record state (update/delete operations only)
|
|
386
|
+
recordId, // Record ID (delete operations in after hooks)
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
**Important Notes**:
|
|
391
|
+
- `oldState` is only available for `update` and `delete` operations
|
|
392
|
+
- It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
|
|
393
|
+
- The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
|
|
394
|
+
- For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
|
|
395
|
+
- `oldState` is captured from `record.__data` or the record itself, providing access to the raw data structure
|
|
396
|
+
|
|
397
|
+
### Usage Examples
|
|
398
|
+
|
|
399
|
+
#### Basic Hook Registration
|
|
400
|
+
|
|
401
|
+
```javascript
|
|
402
|
+
import { beforeHook, afterHook } from '@stonyx/orm';
|
|
403
|
+
|
|
404
|
+
// Validation before creating - can halt by returning a value
|
|
405
|
+
beforeHook('create', 'animal', (context) => {
|
|
406
|
+
const { age } = context.body.data.attributes;
|
|
407
|
+
if (age < 0) {
|
|
408
|
+
return 400; // Halt with 400 Bad Request
|
|
409
|
+
}
|
|
410
|
+
// Return undefined to continue
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Logging after updates
|
|
414
|
+
afterHook('update', 'animal', (context) => {
|
|
415
|
+
console.log(`Animal ${context.record.id} was updated`);
|
|
416
|
+
});
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
#### Halting Operations
|
|
420
|
+
|
|
421
|
+
Before hooks can halt operations by returning a value:
|
|
422
|
+
|
|
423
|
+
```javascript
|
|
424
|
+
import { beforeHook } from '@stonyx/orm';
|
|
425
|
+
|
|
426
|
+
// Return a status code to halt with that HTTP status
|
|
427
|
+
beforeHook('create', 'animal', (context) => {
|
|
428
|
+
if (!context.body.data.attributes.name) {
|
|
429
|
+
return 400; // Bad Request
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Return an object to send a custom response
|
|
434
|
+
beforeHook('delete', 'animal', (context) => {
|
|
435
|
+
const animal = store.get('animal', context.params.id);
|
|
436
|
+
if (animal.protected) {
|
|
437
|
+
return { errors: [{ detail: 'Cannot delete protected animals' }] };
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// Return undefined (or nothing) to allow operation to continue
|
|
442
|
+
beforeHook('update', 'animal', (context) => {
|
|
443
|
+
console.log('Update proceeding...');
|
|
444
|
+
// No return = operation continues
|
|
445
|
+
});
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
#### Data Transformation
|
|
449
|
+
|
|
450
|
+
```javascript
|
|
451
|
+
// Normalize data before saving
|
|
452
|
+
beforeHook('create', 'owner', (context) => {
|
|
453
|
+
const attrs = context.body.data.attributes;
|
|
454
|
+
if (attrs.email) {
|
|
455
|
+
attrs.email = attrs.email.toLowerCase().trim();
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
#### Side Effects
|
|
461
|
+
|
|
462
|
+
```javascript
|
|
463
|
+
// Send notification after animal is adopted (using oldState to detect changes)
|
|
464
|
+
afterHook('update', 'animal', async (context) => {
|
|
465
|
+
// Use oldState to compare before/after values
|
|
466
|
+
if (context.oldState && context.oldState.owner !== context.record.owner) {
|
|
467
|
+
await sendNotification({
|
|
468
|
+
type: 'adoption',
|
|
469
|
+
animalId: context.record.id,
|
|
470
|
+
previousOwner: context.oldState.owner,
|
|
471
|
+
newOwner: context.record.owner
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Cache invalidation
|
|
477
|
+
afterHook('delete', 'animal', async (context) => {
|
|
478
|
+
await cache.invalidate(`owner:${context.params.id}:pets`);
|
|
479
|
+
});
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
#### Change Detection
|
|
483
|
+
|
|
484
|
+
The `oldState` property (available for `update` and `delete` operations) enables precise change tracking:
|
|
485
|
+
|
|
486
|
+
```javascript
|
|
487
|
+
// Detect specific field changes
|
|
488
|
+
afterHook('update', 'animal', async (context) => {
|
|
489
|
+
if (!context.oldState) return; // No old state for create operations
|
|
490
|
+
|
|
491
|
+
// Check if a specific field changed
|
|
492
|
+
if (context.oldState.age !== context.record.age) {
|
|
493
|
+
console.log(`Age changed from ${context.oldState.age} to ${context.record.age}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Track multiple field changes
|
|
497
|
+
const changedFields = [];
|
|
498
|
+
for (const key in context.record.__data) {
|
|
499
|
+
if (context.oldState[key] !== context.record.__data[key]) {
|
|
500
|
+
changedFields.push(key);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (changedFields.length > 0) {
|
|
505
|
+
console.log(`Fields changed: ${changedFields.join(', ')}`);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Access deleted record data
|
|
510
|
+
afterHook('delete', 'animal', async (context) => {
|
|
511
|
+
console.log(`Deleted animal: ${context.oldState.type} (age: ${context.oldState.age})`);
|
|
512
|
+
// oldState contains full snapshot of the deleted record
|
|
513
|
+
});
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
#### Authorization
|
|
517
|
+
|
|
518
|
+
```javascript
|
|
519
|
+
// Additional access control - halt with 403 if unauthorized
|
|
520
|
+
beforeHook('delete', 'animal', (context) => {
|
|
521
|
+
const user = context.state.currentUser;
|
|
522
|
+
const animal = store.get('animal', context.params.id);
|
|
523
|
+
|
|
524
|
+
if (animal.owner !== user.id && !user.isAdmin) {
|
|
525
|
+
return 403; // Forbidden
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
#### Auditing
|
|
531
|
+
|
|
532
|
+
```javascript
|
|
533
|
+
// Audit log for all changes with field-level change tracking
|
|
534
|
+
afterHook('update', 'animal', async (context) => {
|
|
535
|
+
// Compare oldState with current record to capture exact changes
|
|
536
|
+
const changes = {};
|
|
537
|
+
if (context.oldState) {
|
|
538
|
+
for (const [key, newValue] of Object.entries(context.record.__data || context.record)) {
|
|
539
|
+
if (context.oldState[key] !== newValue) {
|
|
540
|
+
changes[key] = { from: context.oldState[key], to: newValue };
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
await auditLog.create({
|
|
546
|
+
operation: 'update',
|
|
547
|
+
model: context.model,
|
|
548
|
+
recordId: context.record.id,
|
|
549
|
+
userId: context.state.currentUser?.id,
|
|
550
|
+
timestamp: new Date(),
|
|
551
|
+
changes // Precise field-level changes: { age: { from: 2, to: 3 } }
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// Audit deletes with full record snapshot
|
|
556
|
+
afterHook('delete', 'animal', async (context) => {
|
|
557
|
+
await auditLog.create({
|
|
558
|
+
operation: 'delete',
|
|
559
|
+
model: context.model,
|
|
560
|
+
recordId: context.recordId,
|
|
561
|
+
userId: context.state.currentUser?.id,
|
|
562
|
+
timestamp: new Date(),
|
|
563
|
+
deletedData: context.oldState // Full snapshot of deleted record
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
#### Error Handling
|
|
569
|
+
|
|
570
|
+
For after hooks, wrap in try/catch if errors should not propagate:
|
|
571
|
+
|
|
572
|
+
```javascript
|
|
573
|
+
afterHook('create', 'animal', async (context) => {
|
|
574
|
+
try {
|
|
575
|
+
await sendWelcomeEmail(context.record.owner);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
// Error is logged but doesn't fail the create operation
|
|
578
|
+
console.error('Failed to send welcome email:', error);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### Hook Lifecycle Management
|
|
584
|
+
|
|
585
|
+
#### Unsubscribing
|
|
586
|
+
|
|
587
|
+
```javascript
|
|
588
|
+
import { beforeHook } from '@stonyx/orm';
|
|
589
|
+
|
|
590
|
+
// Get unsubscribe function
|
|
591
|
+
const unsubscribe = beforeHook('create', 'animal', handler);
|
|
592
|
+
|
|
593
|
+
// Later, remove the hook
|
|
594
|
+
unsubscribe();
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
#### Clearing Hooks
|
|
598
|
+
|
|
599
|
+
```javascript
|
|
600
|
+
import { clearHook, clearAllHooks } from '@stonyx/orm';
|
|
601
|
+
|
|
602
|
+
// Remove all hooks for a specific operation:model
|
|
603
|
+
clearHook('create', 'animal');
|
|
604
|
+
|
|
605
|
+
// Remove only before hooks
|
|
606
|
+
clearHook('create', 'animal', 'before');
|
|
607
|
+
|
|
608
|
+
// Remove only after hooks
|
|
609
|
+
clearHook('create', 'animal', 'after');
|
|
610
|
+
|
|
611
|
+
// Remove ALL hooks (useful for testing)
|
|
612
|
+
clearAllHooks();
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
### Advanced Patterns
|
|
616
|
+
|
|
617
|
+
#### Conditional Hooks
|
|
618
|
+
|
|
619
|
+
```javascript
|
|
620
|
+
beforeHook('update', 'animal', (context) => {
|
|
621
|
+
// Only validate if age is being updated
|
|
622
|
+
if ('age' in context.body.data.attributes) {
|
|
623
|
+
const { age } = context.body.data.attributes;
|
|
624
|
+
if (age < 0 || age > 50) {
|
|
625
|
+
return 400; // Bad Request
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
#### Cross-Model Hooks
|
|
632
|
+
|
|
633
|
+
```javascript
|
|
634
|
+
// Update owner's pet count when animal is created
|
|
635
|
+
afterHook('create', 'animal', async (context) => {
|
|
636
|
+
const owner = store.get('owner', context.record.owner);
|
|
637
|
+
if (owner) {
|
|
638
|
+
owner.petCount = (owner.petCount || 0) + 1;
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
#### Sequential Middleware
|
|
644
|
+
|
|
645
|
+
```javascript
|
|
646
|
+
// Multiple hooks run in registration order
|
|
647
|
+
beforeHook('create', 'post', (context) => {
|
|
648
|
+
console.log('First middleware');
|
|
649
|
+
context.customData = { checked: true };
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
beforeHook('create', 'post', (context) => {
|
|
653
|
+
console.log('Second middleware');
|
|
654
|
+
// Can access data from previous hooks
|
|
655
|
+
if (!context.customData?.checked) {
|
|
656
|
+
return 403;
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
### Hook Execution Order
|
|
662
|
+
|
|
663
|
+
1. **Before hooks** fire first (sequentially, in registration order)
|
|
664
|
+
2. **Main operation** executes (if no before hook halted)
|
|
665
|
+
3. **After hooks** fire last (sequentially, in registration order)
|
|
666
|
+
|
|
667
|
+
Before hooks can halt the operation by returning a value. After hooks run after completion and cannot halt.
|
|
668
|
+
|
|
669
|
+
### Best Practices
|
|
670
|
+
|
|
671
|
+
1. **Keep hooks focused**: Each hook should do one thing well
|
|
672
|
+
2. **Use async/await**: Hooks support async functions for consistency
|
|
673
|
+
3. **Return values intentionally**: Only return a value from before hooks when you want to halt
|
|
674
|
+
4. **Document side effects**: Make it clear what each hook does
|
|
675
|
+
5. **Test hooks independently**: Write unit tests for hook logic
|
|
676
|
+
6. **Avoid heavy operations**: Keep hooks fast to maintain performance
|
|
677
|
+
7. **Clean up in tests**: Use `clearAllHooks()` in test teardown
|
|
678
|
+
|
|
679
|
+
### Testing Hooks
|
|
680
|
+
|
|
681
|
+
```javascript
|
|
682
|
+
import { beforeHook, clearAllHooks } from '@stonyx/orm';
|
|
683
|
+
|
|
684
|
+
// Clean up after each test
|
|
685
|
+
afterEach(() => {
|
|
686
|
+
clearAllHooks();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// Test that validation hook halts with 400
|
|
690
|
+
test('validation hook rejects negative age', async () => {
|
|
691
|
+
beforeHook('create', 'animal', (context) => {
|
|
692
|
+
if (context.body.data.attributes.age < 0) {
|
|
693
|
+
return 400;
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const response = await fetch('/animals', {
|
|
698
|
+
method: 'POST',
|
|
699
|
+
body: JSON.stringify({
|
|
700
|
+
data: { attributes: { age: -5 } }
|
|
701
|
+
})
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
assert.strictEqual(response.status, 400, 'Hook halted with 400');
|
|
705
|
+
});
|
|
706
|
+
```
|
|
707
|
+
|
|
292
708
|
## Exported Helpers
|
|
293
709
|
|
|
294
710
|
| Export | Description |
|
|
@@ -297,9 +713,18 @@ GET /animals/1
|
|
|
297
713
|
| `belongsTo` | Define a one-to-one relationship. |
|
|
298
714
|
| `hasMany` | Define a one-to-many relationship. |
|
|
299
715
|
| `createRecord` | Instantiate a record with proper serialization and relationships. |
|
|
716
|
+
| `updateRecord` | Update an existing record with new data. |
|
|
300
717
|
| `store` | Singleton store for all model instances. |
|
|
301
718
|
| `relationships` | Access all relationships (`hasMany`, `belongsTo`, `global`, `pending`). |
|
|
719
|
+
| `beforeHook` | Register a before hook that can halt operations. |
|
|
720
|
+
| `afterHook` | Register an after hook for post-operation logic. |
|
|
721
|
+
| `clearHook` | Clear hooks for a specific operation:model. |
|
|
722
|
+
| `clearAllHooks` | Clear all registered hooks (useful for testing). |
|
|
723
|
+
|
|
724
|
+
## Project Structure
|
|
725
|
+
|
|
726
|
+
For a full architectural reference, see [project-structure.md](project-structure.md).
|
|
302
727
|
|
|
303
728
|
## License
|
|
304
729
|
|
|
305
|
-
Apache —
|
|
730
|
+
Apache 2.0 — see [LICENSE.md](LICENSE.md).
|
package/config/environment.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|