@stonyx/orm 0.2.0 → 0.2.1-alpha.1

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.
@@ -12,6 +12,7 @@
12
12
  4. **Data Persistence**: File-based JSON storage with auto-save
13
13
  5. **REST API Generation**: Auto-generated RESTful endpoints with access control
14
14
  6. **Data Transformation**: Custom type conversion and formatting
15
+ 7. **Event System**: Pub/sub events for CRUD operations with before/after hooks
15
16
 
16
17
  ---
17
18
 
@@ -28,28 +29,29 @@
28
29
  7. **Relationships** ([src/has-many.js](src/has-many.js), [src/belongs-to.js](src/belongs-to.js)) - Relationship handlers
29
30
  8. **Include Parser** ([src/include-parser.js](src/include-parser.js)) - Parses include query params
30
31
  9. **Include Collector** ([src/include-collector.js](src/include-collector.js)) - Collects and deduplicates included records
32
+ 10. **Events** (from `@stonyx/events`) - Pub/sub event system for CRUD hooks
31
33
 
32
34
  ### Project Structure
33
35
 
34
36
  ```
35
37
  stonyx-orm/
36
38
  ├── src/
37
- │ ├── index.js # Main exports
39
+ │ ├── index.js # Main exports (includes ormEvents)
38
40
  │ ├── main.js # Orm class
39
41
  │ ├── model.js # Base Model
40
42
  │ ├── record.js # Record instances
41
43
  │ ├── serializer.js # Base Serializer
42
- │ ├── store.js # In-memory storage
44
+ │ ├── store.js # In-memory storage (emits delete events)
43
45
  │ ├── db.js # JSON persistence
44
46
  │ ├── attr.js # Attribute helper (Proxy-based)
45
47
  │ ├── has-many.js # One-to-many relationships
46
48
  │ ├── belongs-to.js # Many-to-one relationships
47
49
  │ ├── relationships.js # Relationship registry
48
- │ ├── manage-record.js # createRecord/updateRecord
50
+ │ ├── manage-record.js # createRecord/updateRecord (emits create events)
49
51
  │ ├── model-property.js # Transform handler
50
52
  │ ├── transforms.js # Built-in transforms
51
53
  │ ├── setup-rest-server.js # REST integration
52
- │ ├── orm-request.js # CRUD request handler
54
+ │ ├── orm-request.js # CRUD request handler (emits update events)
53
55
  │ └── meta-request.js # Meta endpoint (dev only)
54
56
  ├── config/
55
57
  │ └── environment.js # Default configuration
@@ -380,9 +382,138 @@ config.orm = {
380
382
  **Dependencies:**
381
383
  - `stonyx` - Main framework (peer)
382
384
  - `@stonyx/utils` - File/string utilities
383
- - `@stonyx/cron` - Scheduled tasks
385
+ - `@stonyx/events` - Pub/sub event system for CRUD hooks
386
+ - `@stonyx/cron` - Scheduled tasks (used by DB for auto-save)
384
387
  - `@stonyx/rest-server` - REST API
385
388
 
389
+ ## Event System
390
+
391
+ The ORM emits events during CRUD operations, allowing applications to hook into the data lifecycle.
392
+
393
+ ### Event Architecture
394
+
395
+ **Event Source**: `@stonyx/events` (Events class)
396
+ **Integration**: `src/index.js` initializes singleton `ormEvents` instance
397
+ **Event Registration**: 6 events registered on initialization:
398
+ - `create:before`, `create:after`
399
+ - `update:before`, `update:after`
400
+ - `delete:before`, `delete:after`
401
+
402
+ ### Event Emission Points
403
+
404
+ **CREATE Events** - `src/manage-record.js`:
405
+ ```javascript
406
+ // Line ~34: Before serialization
407
+ Events.instance?.emit('create:before', {
408
+ model: modelName,
409
+ record,
410
+ rawData,
411
+ options
412
+ });
413
+
414
+ // Line ~88: After record fully created
415
+ Events.instance?.emit('create:after', {
416
+ model: modelName,
417
+ record,
418
+ data: record.__data,
419
+ rawData,
420
+ options
421
+ });
422
+ ```
423
+
424
+ **UPDATE Events** - `src/orm-request.js` (PATCH handler):
425
+ ```javascript
426
+ // Line ~155: Before applying updates
427
+ const oldData = { ...record.__data };
428
+ Events.instance?.emit('update:before', {
429
+ model,
430
+ record,
431
+ data: record.__data,
432
+ oldData,
433
+ rawData: attributes,
434
+ options: {}
435
+ });
436
+
437
+ // Line ~173: After updates applied
438
+ Events.instance?.emit('update:after', {
439
+ model,
440
+ record,
441
+ data: record.__data,
442
+ oldData,
443
+ rawData: attributes,
444
+ options: {}
445
+ });
446
+ ```
447
+
448
+ **DELETE Events** - `src/store.js` (unloadRecord):
449
+ ```javascript
450
+ // Line ~45: Before cleanup
451
+ Events.instance?.emit('delete:before', {
452
+ model,
453
+ record,
454
+ data: { ...record.__data },
455
+ options
456
+ });
457
+
458
+ // Line ~65: After deletion
459
+ Events.instance?.emit('delete:after', {
460
+ model,
461
+ record: null,
462
+ data: null,
463
+ options
464
+ });
465
+ ```
466
+
467
+ ### Design Decisions
468
+
469
+ **Fire Without Await**: Events are emitted without `await` to maintain backward compatibility
470
+ - `createRecord()` remains synchronous
471
+ - `unloadRecord()` remains synchronous
472
+ - Synchronous handlers execute immediately
473
+ - Async handlers run in background
474
+
475
+ **Optional Chaining**: Uses `Events.instance?.emit()` for zero overhead when not used
476
+
477
+ **Error Isolation**: Event errors are caught in Events class, never crash ORM operations
478
+
479
+ ### Usage Patterns
480
+
481
+ **Auditing**:
482
+ ```javascript
483
+ import { ormEvents } from '@stonyx/orm';
484
+
485
+ ormEvents.subscribe('update:after', ({ model, data, oldData }) => {
486
+ auditLog.write({
487
+ action: 'update',
488
+ model,
489
+ changes: diff(oldData, data),
490
+ timestamp: Date.now()
491
+ });
492
+ });
493
+ ```
494
+
495
+ **Auto-Timestamps**:
496
+ ```javascript
497
+ ormEvents.subscribe('create:before', ({ record }) => {
498
+ record.__data.createdAt = new Date().toISOString();
499
+ });
500
+
501
+ ormEvents.subscribe('update:before', ({ record }) => {
502
+ record.__data.updatedAt = new Date().toISOString();
503
+ });
504
+ ```
505
+
506
+ **Cache Invalidation**:
507
+ ```javascript
508
+ ormEvents.subscribe('update:after', ({ model, record }) => {
509
+ cache.invalidate(`${model}:${record.id}`);
510
+ });
511
+
512
+ ormEvents.subscribe('delete:after', ({ model, record }) => {
513
+ if (record) cache.invalidate(`${model}:${record.id}`);
514
+ });
515
+ ```
516
+
386
517
  ---
387
518
 
388
519
  ## Critical Files for Common Tasks
@@ -412,7 +543,7 @@ config.orm = {
412
543
 
413
544
  **Import the ORM:**
414
545
  ```javascript
415
- import { Orm, Model, Serializer, attr, hasMany, belongsTo, createRecord, updateRecord, store } from '@stonyx/orm';
546
+ import { Orm, Model, Serializer, attr, hasMany, belongsTo, createRecord, updateRecord, store, ormEvents } from '@stonyx/orm';
416
547
  ```
417
548
 
418
549
  **Initialize:**
@@ -2,35 +2,15 @@ name: CI
2
2
 
3
3
  on:
4
4
  pull_request:
5
- branches:
6
- - dev
7
- - main
5
+ branches: [dev, main]
8
6
 
9
7
  concurrency:
10
8
  group: ci-${{ github.head_ref || github.ref }}
11
9
  cancel-in-progress: true
12
10
 
11
+ permissions:
12
+ contents: read
13
+
13
14
  jobs:
14
15
  test:
15
- runs-on: ubuntu-latest
16
-
17
- steps:
18
- - name: Checkout code
19
- uses: actions/checkout@v3
20
-
21
- - name: Setup pnpm
22
- uses: pnpm/action-setup@v4
23
- with:
24
- version: 9
25
-
26
- - name: Set up Node.js
27
- uses: actions/setup-node@v3
28
- with:
29
- node-version: 24.13.0
30
- cache: 'pnpm'
31
-
32
- - name: Install dependencies
33
- run: pnpm install --frozen-lockfile
34
-
35
- - name: Run tests
36
- run: pnpm test
16
+ uses: abofs/stonyx-workflows/.github/workflows/ci.yml@main
@@ -0,0 +1,35 @@
1
+ name: Publish to NPM
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ version-type:
7
+ description: 'Version type'
8
+ required: true
9
+ type: choice
10
+ options:
11
+ - patch
12
+ - minor
13
+ - major
14
+ custom-version:
15
+ description: 'Custom version (optional, overrides version-type)'
16
+ required: false
17
+ type: string
18
+ pull_request:
19
+ types: [opened, synchronize, reopened]
20
+ branches: [main, dev]
21
+ push:
22
+ branches: [main]
23
+
24
+ permissions:
25
+ contents: write
26
+ id-token: write
27
+ pull-requests: write
28
+
29
+ jobs:
30
+ publish:
31
+ uses: abofs/stonyx-workflows/.github/workflows/npm-publish.yml@main
32
+ with:
33
+ version-type: ${{ github.event.inputs.version-type }}
34
+ custom-version: ${{ github.event.inputs.custom-version }}
35
+ secrets: inherit
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.2.0",
7
+ "version": "0.2.1-alpha.1",
8
8
  "description": "",
9
9
  "main": "src/main.js",
10
10
  "type": "module",
@@ -12,9 +12,6 @@
12
12
  ".": "./src/index.js",
13
13
  "./db": "./src/exports/db.js"
14
14
  },
15
- "scripts": {
16
- "test": "qunit --require ./stonyx-bootstrap.cjs"
17
- },
18
15
  "repository": {
19
16
  "type": "git",
20
17
  "url": "git+https://github.com/abofs/stonyx-orm.git"
@@ -25,20 +22,25 @@
25
22
  "Stone Costa <stone.costa@synamicd.com>"
26
23
  ],
27
24
  "publishConfig": {
28
- "access": "public"
25
+ "access": "public",
26
+ "provenance": true
29
27
  },
30
28
  "bugs": {
31
29
  "url": "https://github.com/abofs/stonyx-orm/issues"
32
30
  },
33
31
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
34
32
  "dependencies": {
35
- "stonyx": "^0.2.2"
33
+ "stonyx": "^0.2.2",
34
+ "@stonyx/events": "^0.1.0",
35
+ "@stonyx/cron": "^0.2.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@stonyx/cron": "^0.2.0",
39
38
  "@stonyx/rest-server": "^0.2.0",
40
39
  "@stonyx/utils": "^0.2.2",
41
40
  "qunit": "^2.24.1",
42
41
  "sinon": "^21.0.0"
42
+ },
43
+ "scripts": {
44
+ "test": "qunit --require ./stonyx-bootstrap.cjs"
43
45
  }
44
- }
46
+ }
@@ -22,6 +22,8 @@ export default class ModelProperty {
22
22
  return this._value = newValue;
23
23
  }
24
24
 
25
+ if (newValue === undefined || newValue === null) return;
26
+
25
27
  this._value = Orm.instance.transforms[this.type](newValue);
26
28
  }
27
29
  }
@@ -114,6 +114,51 @@ function parseInclude(includeParam) {
114
114
  .map(rel => rel.split('.')); // Parse nested paths: "owner.pets" → ["owner", "pets"]
115
115
  }
116
116
 
117
+ function parseFields(query) {
118
+ const fields = new Map();
119
+ if (!query) return fields;
120
+
121
+ for (const [key, value] of Object.entries(query)) {
122
+ const match = key.match(/^fields\[(\w+)\]$/);
123
+ if (match && typeof value === 'string') {
124
+ const modelName = match[1];
125
+ const fieldNames = value.split(',').map(f => f.trim()).filter(f => f);
126
+ fields.set(modelName, new Set(fieldNames));
127
+ }
128
+ }
129
+
130
+ return fields;
131
+ }
132
+
133
+ function parseFilters(query) {
134
+ const filters = [];
135
+ if (!query) return filters;
136
+
137
+ for (const [key, value] of Object.entries(query)) {
138
+ const match = key.match(/^filter\[(.+)\]$/);
139
+ if (match && typeof value === 'string') {
140
+ filters.push({ path: match[1].split('.'), value });
141
+ }
142
+ }
143
+
144
+ return filters;
145
+ }
146
+
147
+ function createFilterPredicate(filters) {
148
+ if (filters.length === 0) return null;
149
+
150
+ return (record) => filters.every(({ path, value }) => {
151
+ let current = record;
152
+
153
+ for (const segment of path) {
154
+ if (current == null) return false;
155
+ current = current[segment];
156
+ }
157
+
158
+ return String(current) === value;
159
+ });
160
+ }
161
+
117
162
  export default class OrmRequest extends Request {
118
163
  constructor({ model, access }) {
119
164
  super(...arguments);
@@ -123,20 +168,30 @@ export default class OrmRequest extends Request {
123
168
 
124
169
  this.handlers = {
125
170
  get: {
126
- [`/${pluralizedModel}`]: (request, { filter }) => {
171
+ [`/${pluralizedModel}`]: (request, { filter: accessFilter }) => {
127
172
  const allRecords = Array.from(store.get(model).values());
128
- const recordsToReturn = filter ? allRecords.filter(filter) : allRecords;
129
- const data = recordsToReturn.map(record => record.toJSON());
130
173
 
174
+ const queryFilters = parseFilters(request.query);
175
+ const queryFilterPredicate = createFilterPredicate(queryFilters);
176
+ const fieldsMap = parseFields(request.query);
177
+ const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
178
+
179
+ let recordsToReturn = allRecords;
180
+ if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter);
181
+ if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
182
+
183
+ const data = recordsToReturn.map(record => record.toJSON({ fields: modelFields }));
131
184
  return buildResponse(data, request.query?.include, recordsToReturn);
132
185
  },
133
186
 
134
187
  [`/${pluralizedModel}/:id`]: (request) => {
135
188
  const record = store.get(model, getId(request.params));
189
+ if (!record) return 404;
136
190
 
137
- if (!record) return 404; // Record not found
191
+ const fieldsMap = parseFields(request.query);
192
+ const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
138
193
 
139
- return buildResponse(record.toJSON(), request.query?.include, record);
194
+ return buildResponse(record.toJSON({ fields: modelFields }), request.query?.include, record);
140
195
  }
141
196
  },
142
197
 
@@ -160,14 +215,19 @@ export default class OrmRequest extends Request {
160
215
  },
161
216
 
162
217
  post: {
163
- [`/${pluralizedModel}`]: ({ body }) => {
164
- const { attributes } = body?.data || {};
218
+ [`/${pluralizedModel}`]: ({ body, query }) => {
219
+ const { type, attributes } = body?.data || {};
165
220
 
166
- if (!attributes) return 400; // Bad request
221
+ if (!type) return 400; // Bad request
222
+
223
+ const fieldsMap = parseFields(query);
224
+ const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
225
+ // Check for duplicate ID
226
+ if (attributes?.id !== undefined && store.get(model, attributes.id)) return 409; // Conflict
167
227
 
168
228
  const record = createRecord(model, attributes, { serialize: false });
169
229
 
170
- return { data: record.toJSON() };
230
+ return { data: record.toJSON({ fields: modelFields }) };
171
231
  }
172
232
  },
173
233
 
package/src/record.js CHANGED
@@ -48,21 +48,29 @@ export default class Record {
48
48
  }
49
49
 
50
50
  // Formats record for JSON API output
51
- toJSON() {
51
+ toJSON(options = {}) {
52
52
  if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
53
-
53
+
54
54
  const { __data:data } = this;
55
+ const { fields } = options;
55
56
  const relationships = {};
56
- const attributes = { ...data };
57
- delete attributes.id;
57
+ const attributes = {};
58
+
59
+ for (const [key, value] of Object.entries(data)) {
60
+ if (key === 'id') continue;
61
+ if (fields && !fields.has(key)) continue;
62
+ attributes[key] = value;
63
+ }
58
64
 
59
65
  for (const [key, getter] of getComputedProperties(this.__model)) {
66
+ if (fields && !fields.has(key)) continue;
60
67
  attributes[key] = getter.call(this);
61
68
  }
62
69
 
63
- for (const [ key, childRecord ] of Object.entries(this.__relationships)) {
70
+ for (const [key, childRecord] of Object.entries(this.__relationships)) {
71
+ if (fields && !fields.has(key)) continue;
64
72
  relationships[key] = {
65
- data: Array.isArray(childRecord)
73
+ data: Array.isArray(childRecord)
66
74
  ? childRecord.map(r => ({ type: r.__model.__name, id: r.id }))
67
75
  : childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null
68
76
  };