@stonyx/orm 0.2.0 → 0.2.3-alpha.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.
@@ -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:**
@@ -0,0 +1,143 @@
1
+ name: Publish to NPM
2
+
3
+ on:
4
+ # Manual trigger (kept for flexibility)
5
+ workflow_dispatch:
6
+ inputs:
7
+ version-type:
8
+ description: 'Version type'
9
+ required: true
10
+ type: choice
11
+ options:
12
+ - alpha
13
+ - patch
14
+ - minor
15
+ - major
16
+ custom-version:
17
+ description: 'Custom version (optional, overrides version-type)'
18
+ required: false
19
+ type: string
20
+
21
+ # Auto-publish alpha on PR
22
+ pull_request:
23
+ types: [opened, synchronize, reopened]
24
+ branches: [main, dev]
25
+
26
+ # Auto-publish stable on merge to main
27
+ push:
28
+ branches: [main]
29
+
30
+ permissions:
31
+ contents: write
32
+ id-token: write # Required for npm provenance
33
+ pull-requests: write # For PR comments
34
+
35
+ jobs:
36
+ publish:
37
+ runs-on: ubuntu-latest
38
+
39
+ steps:
40
+ - name: Checkout code
41
+ uses: actions/checkout@v3
42
+ with:
43
+ fetch-depth: 0
44
+ # For PR events, check out the PR branch
45
+ ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
46
+
47
+ - name: Setup pnpm
48
+ uses: pnpm/action-setup@v4
49
+ with:
50
+ version: 9
51
+
52
+ - name: Set up Node.js
53
+ uses: actions/setup-node@v3
54
+ with:
55
+ node-version: 24.13.0
56
+ cache: 'pnpm'
57
+ registry-url: 'https://registry.npmjs.org'
58
+
59
+ - name: Install dependencies
60
+ run: pnpm install --frozen-lockfile
61
+
62
+ - name: Run tests
63
+ run: pnpm test
64
+
65
+ - name: Configure git
66
+ run: |
67
+ git config user.name "github-actions[bot]"
68
+ git config user.email "github-actions[bot]@users.noreply.github.com"
69
+
70
+ # Determine version type based on trigger
71
+ - name: Determine version bump type
72
+ id: version-type
73
+ run: |
74
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
75
+ echo "type=alpha" >> $GITHUB_OUTPUT
76
+ elif [ "${{ github.event_name }}" = "push" ]; then
77
+ echo "type=patch" >> $GITHUB_OUTPUT
78
+ elif [ "${{ github.event.inputs.custom-version }}" != "" ]; then
79
+ echo "type=custom" >> $GITHUB_OUTPUT
80
+ else
81
+ echo "type=${{ github.event.inputs.version-type }}" >> $GITHUB_OUTPUT
82
+ fi
83
+
84
+ # Version bumping
85
+ - name: Bump version (custom)
86
+ if: steps.version-type.outputs.type == 'custom'
87
+ run: pnpm version ${{ github.event.inputs.custom-version }} --no-git-tag-version
88
+
89
+ - name: Bump version (alpha)
90
+ if: steps.version-type.outputs.type == 'alpha'
91
+ run: pnpm version prerelease --preid=alpha --no-git-tag-version
92
+
93
+ - name: Bump version (patch/minor/major)
94
+ if: steps.version-type.outputs.type == 'patch' || steps.version-type.outputs.type == 'minor' || steps.version-type.outputs.type == 'major'
95
+ run: pnpm version ${{ steps.version-type.outputs.type }} --no-git-tag-version
96
+
97
+ - name: Get package version
98
+ id: package-version
99
+ run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
100
+
101
+ # Publishing
102
+ - name: Publish to NPM (alpha)
103
+ if: contains(steps.package-version.outputs.version, 'alpha')
104
+ run: pnpm publish --tag alpha --access public --no-git-checks
105
+
106
+ - name: Publish to NPM (stable)
107
+ if: "!contains(steps.package-version.outputs.version, 'alpha')"
108
+ run: pnpm publish --access public
109
+
110
+ # Only commit and tag for stable releases (push to main or manual stable)
111
+ - name: Commit version bump and create tag
112
+ if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && !contains(steps.package-version.outputs.version, 'alpha'))
113
+ run: |
114
+ git add package.json
115
+ git commit -m "chore: release v${{ steps.package-version.outputs.version }}"
116
+ git tag v${{ steps.package-version.outputs.version }}
117
+ git push origin main --tags
118
+
119
+ - name: Create GitHub Release
120
+ if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && !contains(steps.package-version.outputs.version, 'alpha'))
121
+ uses: actions/create-release@v1
122
+ env:
123
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
124
+ with:
125
+ tag_name: v${{ steps.package-version.outputs.version }}
126
+ release_name: v${{ steps.package-version.outputs.version }}
127
+ draft: false
128
+ prerelease: false
129
+
130
+ # Add PR comment with alpha version info
131
+ - name: Comment on PR with alpha version
132
+ if: github.event_name == 'pull_request'
133
+ uses: actions/github-script@v6
134
+ with:
135
+ script: |
136
+ const version = '${{ steps.package-version.outputs.version }}';
137
+ const packageName = require('./package.json').name;
138
+ github.rest.issues.createComment({
139
+ issue_number: context.issue.number,
140
+ owner: context.repo.owner,
141
+ repo: context.repo.repo,
142
+ body: `## 🚀 Alpha Version Published\n\n**Version:** \`${version}\`\n\n**Install:**\n\`\`\`bash\npnpm add ${packageName}@${version}\n# or\npnpm add ${packageName}@alpha # latest alpha\n\`\`\`\n\nThis alpha version is now available for testing!`
143
+ });
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.3-alpha.0",
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,17 @@ export default class OrmRequest extends Request {
160
215
  },
161
216
 
162
217
  post: {
163
- [`/${pluralizedModel}`]: ({ body }) => {
164
- const { attributes } = body?.data || {};
218
+ [`/${pluralizedModel}`]: (request) => {
219
+ const { attributes } = request.body?.data || {};
165
220
 
166
221
  if (!attributes) return 400; // Bad request
167
222
 
223
+ const fieldsMap = parseFields(request.query);
224
+ const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
225
+
168
226
  const record = createRecord(model, attributes, { serialize: false });
169
227
 
170
- return { data: record.toJSON() };
228
+ return { data: record.toJSON({ fields: modelFields }) };
171
229
  }
172
230
  },
173
231
 
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
  };