@stonyx/orm 0.1.0 → 0.2.1-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.
@@ -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/README.md CHANGED
@@ -1,13 +1,305 @@
1
- # stonyx-orm
1
+ # @stonyx/orm
2
2
 
3
- ## Running the test suite
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.
5
+
6
+ ## Highlights
7
+
8
+ - **Automatic Loading**: Models, serializers, transforms, and access classes are auto-registered from their configured directories.
9
+ - **Models**: Define attributes with type-safe proxies (`attr`) and relationships (`hasMany`, `belongsTo`).
10
+ - **Serializers**: Map raw data into model-friendly structures, including nested properties.
11
+ - **Transforms**: Apply custom transformations on data values automatically.
12
+ - **DB Integration**: Optional file-based persistence with auto-save support.
13
+ - **REST Server Integration**: Automatic route setup with customizable access control.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @stonyx/orm
19
+ ````
20
+
21
+ ## Usage example
22
+
23
+ This module is part of the **Stonyx framework**. To use it, first configure the `restServer` key in your `environment.js` file:
24
+
25
+ ```js
26
+ const {
27
+ ORM_ACCESS_PATH,
28
+ ORM_MODEL_PATH,
29
+ ORM_REST_ROUTE,
30
+ ORM_SERIALIZER_PATH,
31
+ ORM_TRANSFORM_PATH,
32
+ ORM_USE_REST_SERVER,
33
+ DB_AUTO_SAVE,
34
+ DB_FILE,
35
+ DB_SCHEMA_PATH,
36
+ DB_SAVE_INTERVAL
37
+ } = process;
38
+
39
+ export default {
40
+ orm: {
41
+ logColor: 'white',
42
+ logMethod: 'db',
43
+
44
+ db: {
45
+ autosave: DB_AUTO_SAVE ?? 'false',
46
+ file: DB_FILE ?? 'db.json',
47
+ saveInterval: DB_SAVE_INTERVAL ?? 3600, // 1 hour
48
+ schema: DB_SCHEMA_PATH ?? './config/db-schema.js'
49
+ },
50
+ paths: {
51
+ access: ORM_ACCESS_PATH ?? './access',
52
+ model: ORM_MODEL_PATH ?? './models',
53
+ serializer: ORM_SERIALIZER_PATH ?? './serializers',
54
+ transform: ORM_TRANSFORM_PATH ?? './transforms'
55
+ },
56
+ restServer: {
57
+ enabled: ORM_USE_REST_SERVER ?? 'true',
58
+ route: ORM_REST_ROUTE ?? '/'
59
+ }
60
+ }
61
+ };
62
+ ```
63
+
64
+ Then initialize the Stonyx framework, which auto-initializes all of its modules, including `@stonyx/rest-server`:
65
+
66
+ ```js
67
+ import Stonyx from 'stonyx';
68
+ import config from './config/environment.js';
69
+
70
+ new Stonyx(config);
71
+ ```
72
+
73
+ For further framework initialization instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
74
+
75
+ ## Models
76
+
77
+ Define a model with attributes and relationships:
78
+
79
+ ```js
80
+ import { Model, attr, hasMany, belongsTo } from '@stonyx/orm';
81
+
82
+ export default class OwnerModel extends Model {
83
+ id = attr('string');
84
+ age = attr('number');
85
+ pets = hasMany('animal');
86
+
87
+ get totalPets() {
88
+ return this.pets.length;
89
+ }
90
+ }
91
+ ```
92
+
93
+ ## Serializers
94
+
95
+ Based on the following sample payload structure which represents a poorly structure third-party data source:
96
+
97
+ ```js
98
+ export default {
99
+ animals: [
100
+ { id: 1, type: 'dog', details: { age: 2, c: 'small', x: 'black', location: { type: 'farm', owner: 'angela' }}},
101
+ //...
102
+ ]
103
+ }
104
+ ```
105
+
106
+ Map raw data to model fields:
107
+
108
+ ```js
109
+ import { Serializer } from '@stonyx/orm';
110
+
111
+ export default class AnimalSerializer extends Serializer {
112
+ map = {
113
+ age: 'details.age',
114
+ size: 'details.c',
115
+ color: 'details.x',
116
+ owner: 'details.location.owner'
117
+ }
118
+ }
119
+ ```
120
+
121
+ ## Relationships
122
+
123
+ ### belongsTo
124
+
125
+ ```js
126
+ import { belongsTo } from '@stonyx/orm';
127
+
128
+ class AnimalModel extends Model {
129
+ owner = belongsTo('owner');
130
+ }
131
+ ```
132
+
133
+ ### hasMany
134
+
135
+ ```js
136
+ import { hasMany } from '@stonyx/orm';
137
+
138
+ class OwnerModel extends Model {
139
+ pets = hasMany('animal');
140
+ }
141
+ ```
142
+
143
+ ## Transforms
144
+
145
+ Apply custom transforms on field values:
146
+
147
+ ```js
148
+ import { ANIMALS } from '../constants.js';
149
+
150
+ export default function(value) {
151
+ return ANIMALS.indexOf(value) || 0;
152
+ }
153
+ ```
154
+
155
+ ## Database (DB) Integration
156
+
157
+ The ORM can automatically save records to a JSON file with optional auto-save intervals.
158
+
159
+ ```js
160
+ import Orm from '@stonyx/orm';
161
+
162
+ const orm = new Orm();
163
+ await orm.init();
164
+
165
+ // Access the DB record
166
+ const dbRecord = Orm.db;
167
+ ```
168
+
169
+ Configuration options are in `config/environment.js`:
170
+
171
+ * `DB_AUTO_SAVE`: Whether to auto-save.
172
+ * `DB_FILE`: File path to store data.
173
+ * `DB_SAVE_INTERVAL`: Interval in seconds for auto-save.
174
+ * `DB_SCHEMA_PATH`: Path to DB schema.
175
+
176
+ ## REST Server Integration
177
+
178
+ The ORM can automatically register REST routes using your access classes.
179
+
180
+ ```js
181
+ import setupRestServer from '@stonyx/orm/setup-rest-server';
182
+
183
+ await setupRestServer('/', './access');
184
+ ```
185
+
186
+ Access classes define models and provide custom filtering/authorization logic:
187
+
188
+ ```js
189
+ export default class GlobalAccess {
190
+ models = ['owner', 'animal'];
191
+
192
+ access(request) {
193
+ if (request.url.endsWith('/owner/angela')) return false;
194
+ return ['read', 'create', 'update', 'delete'];
195
+ }
196
+ }
197
+ ```
198
+
199
+ ### Include Parameter (Sideloading Relationships)
200
+
201
+ The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
202
+
203
+ #### Basic Usage
204
+
205
+ ```javascript
206
+ // Fetch animal with owner and traits included
207
+ GET /animals/1?include=owner,traits
208
+
209
+ // Response:
210
+ {
211
+ "data": {
212
+ "type": "animal",
213
+ "id": 1,
214
+ "attributes": { "age": 2, "size": "small" },
215
+ "relationships": {
216
+ "owner": { "data": { "type": "owner", "id": "angela" } },
217
+ "traits": { "data": [
218
+ { "type": "trait", "id": 1 },
219
+ { "type": "trait", "id": 2 }
220
+ ]}
221
+ }
222
+ },
223
+ "included": [
224
+ {
225
+ "type": "owner",
226
+ "id": "angela",
227
+ "attributes": { "age": 36, "gender": "female" },
228
+ "relationships": { "pets": { "data": [...] } }
229
+ },
230
+ {
231
+ "type": "trait",
232
+ "id": 1,
233
+ "attributes": { "type": "habitat", "value": "farm" },
234
+ "relationships": {}
235
+ },
236
+ {
237
+ "type": "trait",
238
+ "id": 2,
239
+ "attributes": { "type": "color", "value": "black" },
240
+ "relationships": {}
241
+ }
242
+ ]
243
+ }
4
244
  ```
5
- npm test
245
+
246
+ #### Features
247
+
248
+ - **Comma-separated relationship names:** `?include=owner,traits`
249
+ - **Nested relationship traversal:** `?include=owner.pets,owner.company` (supports multi-level nesting)
250
+ - **Works with collections and single records:** Both GET endpoints support includes
251
+ - **Automatic deduplication:** Each unique record (by type+id) appears only once in included array
252
+ - **Invalid relationships ignored:** Invalid relationship names are silently skipped
253
+ - **Backward compatible:** Omit the include parameter for original behavior (no included array)
254
+
255
+ #### Examples
256
+
257
+ ```javascript
258
+ // Single resource with single include
259
+ GET /owners/gina?include=pets
260
+
261
+ // Single resource with multiple includes
262
+ GET /animals/1?include=owner,traits
263
+
264
+ // Nested includes (NEW!)
265
+ GET /animals/1?include=owner.pets
266
+
267
+ // Deep nesting (3+ levels)
268
+ GET /scenes/e001-s001?include=slides.dialogue.character
269
+
270
+ // Collection with includes (deduplicates automatically)
271
+ GET /animals?include=owner
272
+
273
+ // Combining nested and non-nested includes
274
+ GET /owners?include=pets.traits,company
275
+
276
+ // No include parameter (backward compatible)
277
+ GET /animals/1
278
+ // Returns: { data: {...} } // No included array
6
279
  ```
7
280
 
8
- ## TODO:
9
- - Config validation
10
- - Use file utils for serializer/model loading
11
- - Usage Documentation
12
- - TEST: Core functionality
13
- - TEST: Base transforms
281
+ **How Nested Includes Work:**
282
+ 1. Query param parsed into path segments: `owner.pets` → `['owner', 'pets']`
283
+ 2. Recursively traverses relationships depth-first
284
+ 3. Deduplication still by type+id (no duplicates in included array)
285
+ 4. Gracefully handles null/missing relationships at any depth
286
+ 5. Each included record gets full `toJSON()` representation
287
+
288
+ #### Limitations
289
+
290
+ - Only available on GET endpoints (not POST/PATCH)
291
+
292
+ ## Exported Helpers
293
+
294
+ | Export | Description |
295
+ | --------------- | ----------------------------------------------------------------------- |
296
+ | `attr` | Define model attributes with type-safe proxy. |
297
+ | `belongsTo` | Define a one-to-one relationship. |
298
+ | `hasMany` | Define a one-to-many relationship. |
299
+ | `createRecord` | Instantiate a record with proper serialization and relationships. |
300
+ | `store` | Singleton store for all model instances. |
301
+ | `relationships` | Access all relationships (`hasMany`, `belongsTo`, `global`, `pending`). |
302
+
303
+ ## License
304
+
305
+ Apache — do what you want, just keep attribution.
@@ -1,7 +1,10 @@
1
1
  const {
2
+ ORM_ACCESS_PATH,
2
3
  ORM_MODEL_PATH,
4
+ ORM_REST_ROUTE,
3
5
  ORM_SERIALIZER_PATH,
4
6
  ORM_TRANSFORM_PATH,
7
+ ORM_USE_REST_SERVER,
5
8
  DB_AUTO_SAVE,
6
9
  DB_FILE,
7
10
  DB_SCHEMA_PATH,
@@ -19,8 +22,13 @@ export default {
19
22
  schema: DB_SCHEMA_PATH ?? './config/db-schema.js'
20
23
  },
21
24
  paths: {
25
+ access: ORM_ACCESS_PATH ?? './access', // Optional for restServer access hooks
22
26
  model: ORM_MODEL_PATH ?? './models',
23
27
  serializer: ORM_SERIALIZER_PATH ?? './serializers',
24
28
  transform: ORM_TRANSFORM_PATH ?? './transforms'
29
+ },
30
+ restServer: {
31
+ enabled: ORM_USE_REST_SERVER ?? 'true', // Whether to load restServer for automatic route setup or
32
+ route: ORM_REST_ROUTE ?? '/',
25
33
  }
26
34
  }
package/package.json CHANGED
@@ -4,17 +4,13 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.0",
7
+ "version": "0.2.1-alpha.0",
8
8
  "description": "",
9
9
  "main": "src/main.js",
10
10
  "type": "module",
11
11
  "exports": {
12
- ".": "./src/main.js",
13
- "./db": "./src/db.js",
14
- "./utils": "./src/utils.js"
15
- },
16
- "scripts": {
17
- "test": "qunit"
12
+ ".": "./src/index.js",
13
+ "./db": "./src/exports/db.js"
18
14
  },
19
15
  "repository": {
20
16
  "type": "git",
@@ -25,16 +21,26 @@
25
21
  "contributors": [
26
22
  "Stone Costa <stone.costa@synamicd.com>"
27
23
  ],
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ },
28
28
  "bugs": {
29
29
  "url": "https://github.com/abofs/stonyx-orm/issues"
30
30
  },
31
31
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
32
32
  "dependencies": {
33
- "stonyx": "^0.1.0"
33
+ "stonyx": "^0.2.2",
34
+ "@stonyx/events": "^0.1.0",
35
+ "@stonyx/cron": "^0.2.0"
34
36
  },
35
37
  "devDependencies": {
36
- "@stonyx/cron": "^0.1.0",
37
- "@stonyx/utils": "^0.1.0",
38
- "qunit": "^2.24.1"
38
+ "@stonyx/rest-server": "^0.2.0",
39
+ "@stonyx/utils": "^0.2.2",
40
+ "qunit": "^2.24.1",
41
+ "sinon": "^21.0.0"
42
+ },
43
+ "scripts": {
44
+ "test": "qunit --require ./stonyx-bootstrap.cjs"
39
45
  }
40
- }
46
+ }
package/src/attr.js ADDED
@@ -0,0 +1,28 @@
1
+ import ModelProperty from './model-property.js';
2
+
3
+ export default function attr() {
4
+ const modelProp = new ModelProperty(...arguments);
5
+
6
+ return new Proxy(modelProp, {
7
+ get(target, prop, receiver) {
8
+ if (prop === 'valueOf' || prop === 'toString') {
9
+ return () => target.value;
10
+ }
11
+
12
+ if (prop in target) {
13
+ return Reflect.get(target, prop, receiver);
14
+ }
15
+
16
+ return target.value;
17
+ },
18
+
19
+ set(target, prop, value, receiver) {
20
+ if (prop === 'value') {
21
+ target.value = value;
22
+ return true;
23
+ }
24
+
25
+ return Reflect.set(target, prop, value, receiver);
26
+ }
27
+ });
28
+ }
@@ -0,0 +1,63 @@
1
+ import { createRecord, relationships, store } from '@stonyx/orm';
2
+ import { getRelationships } from './relationships.js';
3
+
4
+ function getOrSet(map, key, defaultValue) {
5
+ if (!map.has(key)) map.set(key, defaultValue);
6
+ return map.get(key);
7
+ }
8
+
9
+ export default function belongsTo(modelName) {
10
+ const hasManyRelationships = relationships.get('hasMany');
11
+ const pendingHasManyQueue = relationships.get('pending');
12
+ const pendingBelongsToQueue = relationships.get('pendingBelongsTo');
13
+
14
+ return (sourceRecord, rawData, options) => {
15
+ if (!rawData) return null;
16
+
17
+ const { __name: sourceModelName } = sourceRecord.__model;
18
+ const relationshipId = sourceRecord.id;
19
+ const relationshipKey = options._relationshipKey;
20
+ const relationship = getRelationships('belongsTo', sourceModelName, modelName, relationshipId);
21
+ const modelStore = store.get(modelName);
22
+
23
+ // Try to get existing record
24
+ const output = typeof rawData === 'object'
25
+ ? createRecord(modelName, rawData, options)
26
+ : modelStore.get(rawData);
27
+
28
+ // If not found and is a string ID, register as pending
29
+ if (!output && typeof rawData !== 'object') {
30
+ const targetId = rawData;
31
+
32
+ // Register pending belongsTo
33
+ const modelPendingMap = getOrSet(pendingBelongsToQueue, modelName, new Map());
34
+ const targetPendingArray = getOrSet(modelPendingMap, targetId, []);
35
+
36
+ targetPendingArray.push({
37
+ sourceRecord,
38
+ sourceModelName,
39
+ relationshipKey,
40
+ relationshipId
41
+ });
42
+
43
+ relationship.set(relationshipId, null);
44
+ return null;
45
+ }
46
+
47
+ relationship.set(relationshipId, output || {});
48
+
49
+ // Populate hasMany side if the relationship is defined
50
+ const otherSide = hasManyRelationships.get(modelName)?.get(sourceModelName)?.get(output?.id);
51
+
52
+ if (otherSide) {
53
+ otherSide.push(sourceRecord);
54
+
55
+ // Remove pending queue if it was just fulfilled
56
+ const pendingModelRelationships = pendingHasManyQueue.get(sourceModelName);
57
+
58
+ if (pendingModelRelationships) pendingModelRelationships.delete(relationshipId);
59
+ }
60
+
61
+ return output;
62
+ }
63
+ }