@stonyx/orm 0.2.1-beta.8 → 0.2.1-beta.81

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,3 +1,7 @@
1
+ [![CI](https://github.com/abofs/stonyx-orm/actions/workflows/ci.yml/badge.svg)](https://github.com/abofs/stonyx-orm/actions/workflows/ci.yml)
2
+ [![npm version](https://img.shields.io/npm/v/@stonyx/orm.svg)](https://www.npmjs.com/package/@stonyx/orm)
3
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
4
+
1
5
  # @stonyx/orm
2
6
 
3
7
  A lightweight ORM for Stonyx projects, featuring model definitions, serializers, relationships, transforms, and optional REST server integration.
@@ -13,6 +17,23 @@ A lightweight ORM for Stonyx projects, featuring model definitions, serializers,
13
17
  - **REST Server Integration**: Automatic route setup with customizable access control.
14
18
  - **Lifecycle Hooks**: Middleware-based before/after hooks for validation, authorization, side effects, and auditing.
15
19
 
20
+ ## Public API vs Internals
21
+
22
+ Records use a proxy that exposes model attributes as direct properties. Always use direct property access for reading and writing field values:
23
+
24
+ ```js
25
+ // Correct: read/write via the proxy
26
+ const age = record.age;
27
+ record.age = 5;
28
+
29
+ // Correct: iterate fields using the record directly
30
+ for (const key of Object.keys(record.serialize())) {
31
+ console.log(key, record[key]);
32
+ }
33
+ ```
34
+
35
+ All properties prefixed with `__` (`__data`, `__relationships`, `__model`, `__serializer`, `__serialized`) are **internal implementation details** and must not be accessed by consumer code. Bypassing the proxy by reading or writing `__data` directly skips type transforms and change tracking, which can lead to silent data corruption.
36
+
16
37
  ## Installation
17
38
 
18
39
  ```bash
@@ -109,6 +130,22 @@ export default class OwnerModel extends Model {
109
130
  }
110
131
  ```
111
132
 
133
+ ### Overriding Plural Names
134
+
135
+ 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`:
136
+
137
+ ```js
138
+ import { Model, attr } from '@stonyx/orm';
139
+
140
+ export default class PersonModel extends Model {
141
+ static pluralName = 'people';
142
+
143
+ name = attr('string');
144
+ }
145
+ ```
146
+
147
+ The override is picked up automatically during ORM initialization. All routes, JSON:API type references, and MySQL table names will use the overridden value.
148
+
112
149
  ## Serializers
113
150
 
114
151
  Based on the following sample payload structure which represents a poorly structure third-party data source:
@@ -207,6 +244,27 @@ Set the `MYSQL_HOST` environment variable to enable MySQL persistence. The ORM l
207
244
  | `stonyx db:migrate:rollback` | Rollback the most recent migration |
208
245
  | `stonyx db:migrate:status` | Show migration status |
209
246
 
247
+ ### Running MySQL Tests
248
+
249
+ The ORM includes integration tests that run against a real MySQL database. These are optional — all other tests work without MySQL.
250
+
251
+ **One-time setup:**
252
+
253
+ ```bash
254
+ # Requires local MySQL 8.0+ running
255
+ ./scripts/setup-test-db.sh
256
+ ```
257
+
258
+ This creates a `stonyx_orm_test` database with a `stonyx_test` user. Safe to re-run.
259
+
260
+ **Running tests:**
261
+
262
+ ```bash
263
+ npm test
264
+ ```
265
+
266
+ MySQL integration tests run automatically when MySQL is available. In CI (where `CI=true`), they skip gracefully.
267
+
210
268
  ## REST Server Integration
211
269
 
212
270
  The ORM can automatically register REST routes using your access classes.
@@ -376,7 +434,7 @@ Each hook receives a context object with comprehensive information:
376
434
  - It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
377
435
  - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
378
436
  - 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
437
+ - `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
380
438
 
381
439
  ### Usage Examples
382
440
 
@@ -479,8 +537,8 @@ afterHook('update', 'animal', async (context) => {
479
537
 
480
538
  // Track multiple field changes
481
539
  const changedFields = [];
482
- for (const key in context.record.__data) {
483
- if (context.oldState[key] !== context.record.__data[key]) {
540
+ for (const key of Object.keys(context.oldState)) {
541
+ if (context.oldState[key] !== context.record[key]) {
484
542
  changedFields.push(key);
485
543
  }
486
544
  }
@@ -519,9 +577,9 @@ afterHook('update', 'animal', async (context) => {
519
577
  // Compare oldState with current record to capture exact changes
520
578
  const changes = {};
521
579
  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 };
580
+ for (const key of Object.keys(context.oldState)) {
581
+ if (context.oldState[key] !== context.record[key]) {
582
+ changes[key] = { from: context.oldState[key], to: context.record[key] };
525
583
  }
526
584
  }
527
585
  }
@@ -4,6 +4,7 @@ const {
4
4
  ORM_REST_ROUTE,
5
5
  ORM_SERIALIZER_PATH,
6
6
  ORM_TRANSFORM_PATH,
7
+ ORM_VIEW_PATH,
7
8
  ORM_USE_REST_SERVER,
8
9
  DB_AUTO_SAVE,
9
10
  DB_FILE,
@@ -36,7 +37,8 @@ export default {
36
37
  access: ORM_ACCESS_PATH ?? './access', // Optional for restServer access hooks
37
38
  model: ORM_MODEL_PATH ?? './models',
38
39
  serializer: ORM_SERIALIZER_PATH ?? './serializers',
39
- transform: ORM_TRANSFORM_PATH ?? './transforms'
40
+ transform: ORM_TRANSFORM_PATH ?? './transforms',
41
+ view: ORM_VIEW_PATH ?? './views'
40
42
  },
41
43
  mysql: MYSQL_HOST ? {
42
44
  host: MYSQL_HOST ?? 'localhost',
package/package.json CHANGED
@@ -4,13 +4,17 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.2.1-beta.8",
7
+ "version": "0.2.1-beta.81",
8
8
  "description": "",
9
9
  "main": "src/main.js",
10
10
  "type": "module",
11
+ "bin": {
12
+ "stonyx-orm": "./src/cli.js"
13
+ },
11
14
  "exports": {
12
15
  ".": "./src/index.js",
13
16
  "./db": "./src/exports/db.js",
17
+ "./standalone-db": "./src/standalone-db.js",
14
18
  "./migrate": "./src/migrate.js",
15
19
  "./commands": "./src/commands.js",
16
20
  "./hooks": "./src/hooks.js"
@@ -24,6 +28,11 @@
24
28
  "contributors": [
25
29
  "Stone Costa <stone.costa@synamicd.com>"
26
30
  ],
31
+ "files": [
32
+ "src",
33
+ "config",
34
+ "README.md"
35
+ ],
27
36
  "publishConfig": {
28
37
  "access": "public",
29
38
  "provenance": true
@@ -33,21 +42,26 @@
33
42
  },
34
43
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
35
44
  "dependencies": {
36
- "stonyx": "0.2.3-beta.2",
37
- "@stonyx/events": "0.1.1-beta.3",
38
- "@stonyx/cron": "0.2.1-beta.4"
45
+ "@stonyx/cron": "0.2.1-beta.29",
46
+ "@stonyx/events": "0.1.1-beta.9",
47
+ "stonyx": "0.2.3-beta.11"
39
48
  },
40
49
  "peerDependencies": {
50
+ "@stonyx/rest-server": ">=0.2.1-beta.14",
41
51
  "mysql2": "^3.0.0"
42
52
  },
43
53
  "peerDependenciesMeta": {
44
54
  "mysql2": {
45
55
  "optional": true
56
+ },
57
+ "@stonyx/rest-server": {
58
+ "optional": true
46
59
  }
47
60
  },
48
61
  "devDependencies": {
49
- "@stonyx/rest-server": "0.2.1-beta.5",
50
- "@stonyx/utils": "0.2.3-beta.3",
62
+ "@stonyx/rest-server": "0.2.1-beta.30",
63
+ "@stonyx/utils": "0.2.3-beta.7",
64
+ "mysql2": "^3.20.0",
51
65
  "qunit": "^2.24.1",
52
66
  "sinon": "^21.0.0"
53
67
  },
@@ -0,0 +1,93 @@
1
+ export class AggregateProperty {
2
+ constructor(aggregateType, relationship, field) {
3
+ this.aggregateType = aggregateType;
4
+ this.relationship = relationship;
5
+ this.field = field;
6
+ this.mysqlFunction = aggregateType.toUpperCase();
7
+ this.resultType = aggregateType === 'avg' ? 'float' : 'number';
8
+ }
9
+
10
+ compute(relatedRecords) {
11
+ if (!relatedRecords || !Array.isArray(relatedRecords) || relatedRecords.length === 0) {
12
+ if (this.aggregateType === 'min' || this.aggregateType === 'max') return null;
13
+ return 0;
14
+ }
15
+
16
+ switch (this.aggregateType) {
17
+ case 'count':
18
+ return relatedRecords.length;
19
+
20
+ case 'sum':
21
+ return relatedRecords.reduce((acc, record) => {
22
+ const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
23
+ return acc + (isNaN(val) ? 0 : val);
24
+ }, 0);
25
+
26
+ case 'avg': {
27
+ let sum = 0;
28
+ let count = 0;
29
+ for (const record of relatedRecords) {
30
+ const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
31
+ if (!isNaN(val)) {
32
+ sum += val;
33
+ count++;
34
+ }
35
+ }
36
+ return count === 0 ? 0 : sum / count;
37
+ }
38
+
39
+ case 'min': {
40
+ let min = null;
41
+ for (const record of relatedRecords) {
42
+ const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
43
+ if (!isNaN(val) && (min === null || val < min)) min = val;
44
+ }
45
+ return min;
46
+ }
47
+
48
+ case 'max': {
49
+ let max = null;
50
+ for (const record of relatedRecords) {
51
+ const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
52
+ if (!isNaN(val) && (max === null || val > max)) max = val;
53
+ }
54
+ return max;
55
+ }
56
+
57
+ default:
58
+ return null;
59
+ }
60
+ }
61
+ }
62
+
63
+ export function count(relationship) {
64
+ return new AggregateProperty('count', relationship);
65
+ }
66
+
67
+ export function avg(relationshipOrField, field) {
68
+ if (field !== undefined) {
69
+ return new AggregateProperty('avg', relationshipOrField, field);
70
+ }
71
+ return new AggregateProperty('avg', undefined, relationshipOrField);
72
+ }
73
+
74
+ export function sum(relationshipOrField, field) {
75
+ if (field !== undefined) {
76
+ return new AggregateProperty('sum', relationshipOrField, field);
77
+ }
78
+ return new AggregateProperty('sum', undefined, relationshipOrField);
79
+ }
80
+
81
+ export function min(relationshipOrField, field) {
82
+ if (field !== undefined) {
83
+ return new AggregateProperty('min', relationshipOrField, field);
84
+ }
85
+ return new AggregateProperty('min', undefined, relationshipOrField);
86
+ }
87
+
88
+ export function max(relationshipOrField, field) {
89
+ if (field !== undefined) {
90
+ return new AggregateProperty('max', relationshipOrField, field);
91
+ }
92
+ return new AggregateProperty('max', undefined, relationshipOrField);
93
+ }
package/src/belongs-to.js CHANGED
@@ -11,7 +11,7 @@ export default function belongsTo(modelName) {
11
11
  const pendingHasManyQueue = relationships.get('pending');
12
12
  const pendingBelongsToQueue = relationships.get('pendingBelongsTo');
13
13
 
14
- return (sourceRecord, rawData, options) => {
14
+ const fn = (sourceRecord, rawData, options) => {
15
15
  if (!rawData) return null;
16
16
 
17
17
  const { __name: sourceModelName } = sourceRecord.__model;
@@ -21,9 +21,13 @@ export default function belongsTo(modelName) {
21
21
  const modelStore = store.get(modelName);
22
22
 
23
23
  // Try to get existing record
24
- const output = typeof rawData === 'object'
25
- ? createRecord(modelName, rawData, options)
26
- : modelStore.get(rawData);
24
+ let output;
25
+
26
+ if (typeof rawData === 'object') {
27
+ output = createRecord(modelName, rawData, options);
28
+ } else if (modelStore) {
29
+ output = modelStore.get(rawData);
30
+ }
27
31
 
28
32
  // If not found and is a string ID, register as pending
29
33
  if (!output && typeof rawData !== 'object') {
@@ -60,4 +64,7 @@ export default function belongsTo(modelName) {
60
64
 
61
65
  return output;
62
66
  }
67
+
68
+ Object.defineProperty(fn, '__relatedModelName', { value: modelName });
69
+ return fn;
63
70
  }
package/src/cli.js ADDED
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Standalone CLI for ORM database operations.
5
+ *
6
+ * Performs CRUD operations on the JSON database without requiring
7
+ * the full Stonyx bootstrap. Supports both file and directory modes.
8
+ *
9
+ * Usage:
10
+ * stonyx-orm create <collection> <json-data>
11
+ * stonyx-orm list <collection>
12
+ * stonyx-orm get <collection> <id>
13
+ * stonyx-orm delete <collection> <id>
14
+ *
15
+ * Configuration (environment variables):
16
+ * DB_MODE — 'file' or 'directory' (default: 'directory')
17
+ * DB_PATH — Path to db.json (default: 'db.json')
18
+ * DB_DIRECTORY — Directory name for collection files (default: 'db')
19
+ *
20
+ * Configuration (CLI flag):
21
+ * --config <path> — Path to a JSON config file with { mode, dbPath, directory }
22
+ */
23
+
24
+ import StandaloneDB from './standalone-db.js';
25
+ import fs from 'fs/promises';
26
+
27
+ const USAGE = `Usage: stonyx-orm <command> [options]
28
+
29
+ Commands:
30
+ create <collection> <json-data> Create a record
31
+ list <collection> List all records
32
+ get <collection> <id> Get a record by ID
33
+ delete <collection> <id> Delete a record by ID
34
+
35
+ Options:
36
+ --config <path> Path to JSON config file
37
+ --help Show this help message
38
+
39
+ Environment variables:
40
+ DB_MODE 'file' or 'directory' (default: 'directory')
41
+ DB_PATH Path to db.json (default: 'db.json')
42
+ DB_DIRECTORY Directory name for collection files (default: 'db')`;
43
+
44
+ async function loadConfig(args) {
45
+ const config = {};
46
+
47
+ // Check for --config flag
48
+ const configIndex = args.indexOf('--config');
49
+
50
+ if (configIndex !== -1 && args[configIndex + 1]) {
51
+ const configPath = args[configIndex + 1];
52
+
53
+ try {
54
+ const content = await fs.readFile(configPath, 'utf-8');
55
+ Object.assign(config, JSON.parse(content));
56
+ } catch (err) {
57
+ console.error(`Error reading config file '${configPath}': ${err.message}`);
58
+ process.exit(1);
59
+ }
60
+
61
+ // Remove --config and its value from args
62
+ args.splice(configIndex, 2);
63
+ }
64
+
65
+ // Environment variables override config file, config file overrides defaults
66
+ return {
67
+ mode: process.env.DB_MODE || config.mode || 'directory',
68
+ dbPath: process.env.DB_PATH || config.dbPath || 'db.json',
69
+ directory: process.env.DB_DIRECTORY || config.directory || 'db',
70
+ };
71
+ }
72
+
73
+ function parseArgs(argv) {
74
+ // Strip node binary and script path
75
+ const args = argv.slice(2);
76
+
77
+ if (args.includes('--help') || args.includes('-h') || args.length === 0) {
78
+ console.log(USAGE);
79
+ process.exit(0);
80
+ }
81
+
82
+ return args;
83
+ }
84
+
85
+ async function run() {
86
+ const args = parseArgs(process.argv);
87
+ const config = await loadConfig(args);
88
+ const db = new StandaloneDB(config);
89
+
90
+ const [command, collection, ...rest] = args;
91
+
92
+ if (!command) {
93
+ console.error('Error: No command specified.\n');
94
+ console.log(USAGE);
95
+ process.exit(1);
96
+ }
97
+
98
+ if (!collection && command !== '--help') {
99
+ console.error(`Error: No collection specified for '${command}' command.\n`);
100
+ console.log(USAGE);
101
+ process.exit(1);
102
+ }
103
+
104
+ try {
105
+ switch (command) {
106
+ case 'list': {
107
+ const records = await db.list(collection);
108
+ console.log(JSON.stringify(records, null, 2));
109
+ break;
110
+ }
111
+
112
+ case 'get': {
113
+ const id = rest[0];
114
+
115
+ if (!id) {
116
+ console.error("Error: 'get' command requires an <id> argument.");
117
+ process.exit(1);
118
+ }
119
+
120
+ const record = await db.get(collection, id);
121
+
122
+ if (!record) {
123
+ console.error(`Record with id '${id}' not found in '${collection}'.`);
124
+ process.exit(1);
125
+ }
126
+
127
+ console.log(JSON.stringify(record, null, 2));
128
+ break;
129
+ }
130
+
131
+ case 'create': {
132
+ const jsonStr = rest.join(' ');
133
+
134
+ if (!jsonStr) {
135
+ console.error("Error: 'create' command requires <json-data> argument.");
136
+ process.exit(1);
137
+ }
138
+
139
+ let data;
140
+
141
+ try {
142
+ data = JSON.parse(jsonStr);
143
+ } catch {
144
+ console.error(`Error: Invalid JSON data: ${jsonStr}`);
145
+ process.exit(1);
146
+ }
147
+
148
+ const created = await db.create(collection, data);
149
+ console.log(JSON.stringify(created, null, 2));
150
+ break;
151
+ }
152
+
153
+ case 'delete': {
154
+ const deleteId = rest[0];
155
+
156
+ if (!deleteId) {
157
+ console.error("Error: 'delete' command requires an <id> argument.");
158
+ process.exit(1);
159
+ }
160
+
161
+ const removed = await db.delete(collection, deleteId);
162
+ console.log(JSON.stringify(removed, null, 2));
163
+ break;
164
+ }
165
+
166
+ default:
167
+ console.error(`Error: Unknown command '${command}'.\n`);
168
+ console.log(USAGE);
169
+ process.exit(1);
170
+ }
171
+ } catch (err) {
172
+ console.error(`Error: ${err.message}`);
173
+ process.exit(1);
174
+ }
175
+ }
176
+
177
+ run();
package/src/db.js CHANGED
@@ -147,15 +147,25 @@ export default class DB {
147
147
  const collectionKeys = this.getCollectionKeys();
148
148
 
149
149
  // Write each collection to its own file in parallel
150
- await Promise.all(collectionKeys.map(key =>
151
- updateFile(path.join(dirPath, `${key}.json`), jsonData[key] || [], { json: true })
152
- ));
150
+ // Use createFile for new files, updateFile for existing ones
151
+ await Promise.all(collectionKeys.map(async key => {
152
+ const filePath = path.join(dirPath, `${key}.json`);
153
+ const exists = await fileExists(filePath);
154
+ const data = jsonData[key] || [];
155
+
156
+ if (exists) await updateFile(filePath, data, { json: true });
157
+ else await createFile(filePath, data, { json: true });
158
+ }));
153
159
 
154
160
  // Write empty-array skeleton to db.json
155
161
  const skeleton = {};
156
162
  for (const key of collectionKeys) skeleton[key] = [];
157
163
 
158
- await updateFile(`${config.rootPath}/${file}`, skeleton, { json: true });
164
+ const dbFilePath = `${config.rootPath}/${file}`;
165
+ const dbFileExists = await fileExists(dbFilePath);
166
+
167
+ if (dbFileExists) await updateFile(dbFilePath, skeleton, { json: true });
168
+ else await createFile(dbFilePath, skeleton, { json: true });
159
169
 
160
170
  log.db(`DB has been successfully saved to ${config.orm.db.directory}/ directory`);
161
171
  return;
package/src/has-many.js CHANGED
@@ -16,7 +16,7 @@ export default function hasMany(modelName) {
16
16
  const globalRelationships = relationships.get('global');
17
17
  const pendingRelationships = relationships.get('pending');
18
18
 
19
- return (sourceRecord, rawData, options) => {
19
+ const fn = (sourceRecord, rawData, options) => {
20
20
  const { __name: sourceModelName } = sourceRecord.__model;
21
21
  const relationshipId = sourceRecord.id;
22
22
  const relationship = getRelationships('hasMany', sourceModelName, modelName, relationshipId);
@@ -27,6 +27,10 @@ export default function hasMany(modelName) {
27
27
  let record;
28
28
 
29
29
  if (typeof elementData !== 'object') {
30
+ if (!modelStore) {
31
+ return queuePendingRelationship(pendingRelationshipQueue, pendingRelationships, modelName, elementData);
32
+ }
33
+
30
34
  record = modelStore.get(elementData);
31
35
 
32
36
  if (!record) {
@@ -58,4 +62,7 @@ export default function hasMany(modelName) {
58
62
 
59
63
  return output;
60
64
  }
65
+
66
+ Object.defineProperty(fn, '__relatedModelName', { value: modelName });
67
+ return fn;
61
68
  }
package/src/index.js CHANGED
@@ -15,15 +15,24 @@
15
15
  */
16
16
 
17
17
  import Model from './model.js';
18
+ import View from './view.js';
18
19
  import Serializer from './serializer.js';
19
20
 
20
21
  import attr from './attr.js';
21
22
  import belongsTo from './belongs-to.js';
22
23
  import hasMany from './has-many.js';
23
24
  import { createRecord, updateRecord } from './manage-record.js';
25
+ import { count, avg, sum, min, max } from './aggregates.js';
24
26
 
25
27
  export { default } from './main.js';
26
28
  export { store, relationships } from './main.js';
27
- export { Model, Serializer }; // base classes
29
+ export { Model, View, Serializer }; // base classes
28
30
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
29
- export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
31
+ export { count, avg, sum, min, max }; // aggregate helpers
32
+ export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
33
+
34
+ // Store API:
35
+ // store.get(model, id) — sync, memory-only
36
+ // store.find(model, id) — async, MySQL for memory:false models
37
+ // store.findAll(model) — async, all records
38
+ // store.query(model, conditions) — async, always hits MySQL