@stonyx/orm 0.2.1-alpha.3 → 0.2.1-alpha.31

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/src/hooks.js ADDED
@@ -0,0 +1,124 @@
1
+ /*
2
+ * Copyright 2025 Stone Costa
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the 'License');
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * Middleware-based hooks registry for ORM operations.
19
+ * Unlike event-based hooks, middleware hooks run sequentially and can halt operations.
20
+ */
21
+
22
+ // Map of "operation:model" -> handler[]
23
+ const beforeHooks = new Map();
24
+ const afterHooks = new Map();
25
+
26
+ /**
27
+ * Register a before hook middleware that runs before the operation executes.
28
+ *
29
+ * @param {string} operation - Operation name: 'create', 'update', 'delete', 'get', or 'list'
30
+ * @param {string} model - Model name (e.g., 'user', 'animal')
31
+ * @param {Function} handler - Middleware function (context) => any
32
+ * - Return undefined to continue to next hook/handler
33
+ * - Return any value to halt operation (integer = HTTP status, object = response body)
34
+ * @returns {Function} Unsubscribe function
35
+ */
36
+ export function beforeHook(operation, model, handler) {
37
+ const key = `${operation}:${model}`;
38
+ if (!beforeHooks.has(key)) {
39
+ beforeHooks.set(key, []);
40
+ }
41
+ beforeHooks.get(key).push(handler);
42
+
43
+ // Return unsubscribe function
44
+ return () => {
45
+ const hooks = beforeHooks.get(key);
46
+ if (hooks) {
47
+ const index = hooks.indexOf(handler);
48
+ if (index > -1) hooks.splice(index, 1);
49
+ }
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Register an after hook middleware that runs after the operation completes.
55
+ * After hooks cannot halt operations (they run after completion).
56
+ *
57
+ * @param {string} operation - Operation name
58
+ * @param {string} model - Model name
59
+ * @param {Function} handler - Middleware function (context) => void
60
+ * @returns {Function} Unsubscribe function
61
+ */
62
+ export function afterHook(operation, model, handler) {
63
+ const key = `${operation}:${model}`;
64
+ if (!afterHooks.has(key)) {
65
+ afterHooks.set(key, []);
66
+ }
67
+ afterHooks.get(key).push(handler);
68
+
69
+ // Return unsubscribe function
70
+ return () => {
71
+ const hooks = afterHooks.get(key);
72
+ if (hooks) {
73
+ const index = hooks.indexOf(handler);
74
+ if (index > -1) hooks.splice(index, 1);
75
+ }
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Get all before hooks for an operation:model combination.
81
+ * @param {string} operation
82
+ * @param {string} model
83
+ * @returns {Function[]}
84
+ */
85
+ export function getBeforeHooks(operation, model) {
86
+ const key = `${operation}:${model}`;
87
+ return beforeHooks.get(key) || [];
88
+ }
89
+
90
+ /**
91
+ * Get all after hooks for an operation:model combination.
92
+ * @param {string} operation
93
+ * @param {string} model
94
+ * @returns {Function[]}
95
+ */
96
+ export function getAfterHooks(operation, model) {
97
+ const key = `${operation}:${model}`;
98
+ return afterHooks.get(key) || [];
99
+ }
100
+
101
+ /**
102
+ * Clear registered hooks for a specific operation:model.
103
+ *
104
+ * @param {string} operation - Operation name
105
+ * @param {string} model - Model name
106
+ * @param {string} [type] - 'before' or 'after' (if omitted, clears both)
107
+ */
108
+ export function clearHook(operation, model, type) {
109
+ const key = `${operation}:${model}`;
110
+ if (!type || type === 'before') {
111
+ beforeHooks.set(key, []);
112
+ }
113
+ if (!type || type === 'after') {
114
+ afterHooks.set(key, []);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Clear all hooks (useful for testing).
120
+ */
121
+ export function clearAllHooks() {
122
+ beforeHooks.clear();
123
+ afterHooks.clear();
124
+ }
package/src/index.js CHANGED
@@ -15,14 +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
28
- export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
29
+ export { Model, View, Serializer }; // base classes
30
+ export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
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
package/src/main.js CHANGED
@@ -19,10 +19,12 @@ import config from 'stonyx/config';
19
19
  import log from 'stonyx/log';
20
20
  import { forEachFileImport } from '@stonyx/utils/file';
21
21
  import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
22
+ import { registerPluralName } from './plural-registry.js';
22
23
  import setupRestServer from './setup-rest-server.js';
23
24
  import baseTransforms from './transforms.js';
24
25
  import Store from './store.js';
25
26
  import Serializer from './serializer.js';
27
+ import { setup } from '@stonyx/events';
26
28
 
27
29
  const defaultOptions = {
28
30
  dbType: 'json'
@@ -35,6 +37,7 @@ export default class Orm {
35
37
 
36
38
  models = {};
37
39
  serializers = {};
40
+ views = {};
38
41
  transforms = { ...baseTransforms };
39
42
  warnings = new Set();
40
43
 
@@ -65,7 +68,10 @@ export default class Orm {
65
68
  // Transforms keep their original name, everything else gets converted to PascalCase with the type suffix
66
69
  const alias = type === 'Transform' ? name : `${kebabCaseToPascalCase(name)}${type}`;
67
70
 
68
- if (type === 'Model') Orm.store.set(name, new Map());
71
+ if (type === 'Model') {
72
+ Orm.store.set(name, new Map());
73
+ registerPluralName(name, exported);
74
+ }
69
75
 
70
76
  return this[pluralize(lowerCaseType)][alias] = exported;
71
77
  }, { ignoreAccessFailure: true, rawName: true, recursive: true, recursiveNaming: true });
@@ -74,10 +80,44 @@ export default class Orm {
74
80
  // Wait for imports before db & rest server setup
75
81
  await Promise.all(promises);
76
82
 
77
- if (this.options.dbType !== 'none') {
83
+ // Discover views from paths.view (separate from model/serializer/transform)
84
+ if (paths.view) {
85
+ await forEachFileImport(paths.view, (exported, { name }) => {
86
+ const alias = `${kebabCaseToPascalCase(name)}View`;
87
+ Orm.store.set(name, new Map());
88
+ registerPluralName(name, exported);
89
+ this.views[alias] = exported;
90
+ }, { ignoreAccessFailure: true, rawName: true, recursive: true, recursiveNaming: true });
91
+ }
92
+
93
+ // Setup event names for hooks after models are loaded
94
+ const eventNames = [];
95
+ const operations = ['list', 'get', 'create', 'update', 'delete'];
96
+ const viewOperations = ['list', 'get'];
97
+ const timings = ['before', 'after'];
98
+
99
+ for (const modelName of Orm.store.data.keys()) {
100
+ const isView = this.isView(modelName);
101
+ const ops = isView ? viewOperations : operations;
102
+
103
+ for (const timing of timings) {
104
+ for (const operation of ops) {
105
+ eventNames.push(`${timing}:${operation}:${modelName}`);
106
+ }
107
+ }
108
+ }
109
+
110
+ setup(eventNames);
111
+
112
+ if (config.orm.mysql) {
113
+ const { default: MysqlDB } = await import('./mysql/mysql-db.js');
114
+ this.mysqlDb = new MysqlDB();
115
+ this.db = this.mysqlDb;
116
+ promises.push(this.mysqlDb.init());
117
+ } else if (this.options.dbType !== 'none') {
78
118
  const db = new DB();
79
119
  this.db = db;
80
-
120
+
81
121
  promises.push(db.init());
82
122
  }
83
123
 
@@ -85,10 +125,29 @@ export default class Orm {
85
125
  promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
86
126
  }
87
127
 
128
+ // Wire up memory resolver so store.find() can check model memory flags
129
+ Orm.store._memoryResolver = (modelName) => {
130
+ const { modelClass } = this.getRecordClasses(modelName);
131
+ return modelClass?.memory === true;
132
+ };
133
+
134
+ // Wire up MySQL reference for on-demand queries from store.find()/findAll()
135
+ if (this.mysqlDb) {
136
+ Orm.store._mysqlDb = this.mysqlDb;
137
+ }
138
+
88
139
  Orm.ready = await Promise.all(promises);
89
140
  Orm.initialized = true;
90
141
  }
91
142
 
143
+ async startup() {
144
+ if (this.mysqlDb) await this.mysqlDb.startup();
145
+ }
146
+
147
+ async shutdown() {
148
+ if (this.mysqlDb) await this.mysqlDb.shutdown();
149
+ }
150
+
92
151
  static get db() {
93
152
  if (!Orm.initialized) throw new Error('ORM has not been initialized yet');
94
153
 
@@ -97,13 +156,27 @@ export default class Orm {
97
156
 
98
157
  getRecordClasses(modelName) {
99
158
  const modelClassPrefix = kebabCaseToPascalCase(modelName);
100
-
159
+
160
+ // Check views first, then models
161
+ const viewClass = this.views[`${modelClassPrefix}View`];
162
+ if (viewClass) {
163
+ return {
164
+ modelClass: viewClass,
165
+ serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
166
+ };
167
+ }
168
+
101
169
  return {
102
170
  modelClass: this.models[`${modelClassPrefix}Model`],
103
171
  serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
104
172
  };
105
173
  }
106
174
 
175
+ isView(modelName) {
176
+ const modelClassPrefix = kebabCaseToPascalCase(modelName);
177
+ return !!this.views[`${modelClassPrefix}View`];
178
+ }
179
+
107
180
  // Queue warnings to avoid the same error from being logged in the same iteration
108
181
  warn(message) {
109
182
  this.warnings.add(message);
@@ -14,6 +14,11 @@ export function createRecord(modelName, rawData={}, userOptions={}) {
14
14
 
15
15
  if (!initialized && !options.isDbRecord) throw new Error('ORM is not ready');
16
16
 
17
+ // Guard: read-only views cannot have records created directly
18
+ if (orm?.isView?.(modelName) && !options.isDbRecord) {
19
+ throw new Error(`Cannot create records for read-only view '${modelName}'`);
20
+ }
21
+
17
22
  const modelStore = store.get(modelName);
18
23
  const globalRelationships = relationships.get('global');
19
24
  const pendingRelationships = relationships.get('pending');
@@ -83,6 +88,12 @@ export function createRecord(modelName, rawData={}, userOptions={}) {
83
88
  export function updateRecord(record, rawData, userOptions={}) {
84
89
  if (!rawData) throw new Error('rawData must be passed in to updateRecord call');
85
90
 
91
+ // Guard: read-only views cannot be updated
92
+ const modelName = record?.__model?.__name;
93
+ if (modelName && Orm.instance?.isView?.(modelName)) {
94
+ throw new Error(`Cannot update records for read-only view '${modelName}'`);
95
+ }
96
+
86
97
  const options = { ...defaultOptions, ...userOptions, update:true };
87
98
 
88
99
  record.serialize(rawData, options);
@@ -90,14 +101,29 @@ export function updateRecord(record, rawData, userOptions={}) {
90
101
 
91
102
  /**
92
103
  * gets the next available id based on last record entry.
93
- *
94
- * Note/TODO: Records going into a db should get their id from the db instead
95
- * Atm, i think the best way to do that would be as an id override that happens after the
96
- * record is created
104
+ *
105
+ * In MySQL mode with numeric IDs, assigns a temporary pending ID.
106
+ * MySQL's AUTO_INCREMENT provides the real ID after INSERT.
97
107
  */
98
108
  function assignRecordId(modelName, rawData) {
99
109
  if (rawData.id) return;
100
110
 
111
+ // In MySQL mode with numeric IDs, defer to MySQL auto-increment
112
+ if (Orm.instance?.mysqlDb && !isStringIdModel(modelName)) {
113
+ rawData.id = `__pending_${Date.now()}_${Math.random()}`;
114
+ rawData.__pendingMysqlId = true;
115
+ return;
116
+ }
117
+
101
118
  const modelStore = Array.from(store.get(modelName).values());
102
119
  rawData.id = modelStore.length ? modelStore.at(-1).id + 1 : 1;
103
120
  }
121
+
122
+ function isStringIdModel(modelName) {
123
+ const { modelClass } = Orm.instance.getRecordClasses(modelName);
124
+ if (!modelClass) return false;
125
+
126
+ const model = new modelClass(modelName);
127
+
128
+ return model.id?.type === 'string';
129
+ }
package/src/migrate.js ADDED
@@ -0,0 +1,72 @@
1
+ import config from 'stonyx/config';
2
+ import Orm from '@stonyx/orm';
3
+ import { createFile, createDirectory, readFile, updateFile, deleteDirectory } from '@stonyx/utils/file';
4
+ import { dbKey } from './db.js';
5
+ import path from 'path';
6
+
7
+ function getCollectionKeys() {
8
+ const SchemaClass = Orm.instance.models[`${dbKey}Model`];
9
+ const instance = new SchemaClass();
10
+ const keys = [];
11
+
12
+ for (const key of Object.keys(instance)) {
13
+ if (key === '__name' || key === 'id') continue;
14
+ if (typeof instance[key] === 'function') keys.push(key);
15
+ }
16
+
17
+ return keys;
18
+ }
19
+
20
+ function getDirPath() {
21
+ const { rootPath } = config;
22
+ const { file, directory } = config.orm.db;
23
+ const dbDir = path.dirname(path.resolve(`${rootPath}/${file}`));
24
+
25
+ return path.join(dbDir, directory);
26
+ }
27
+
28
+ export async function fileToDirectory() {
29
+ const { rootPath } = config;
30
+ const { file } = config.orm.db;
31
+ const dbFilePath = path.resolve(`${rootPath}/${file}`);
32
+ const collectionKeys = getCollectionKeys();
33
+ const dirPath = getDirPath();
34
+
35
+ // Read full data from db.json
36
+ const data = await readFile(dbFilePath, { json: true });
37
+
38
+ // Create directory and write each collection
39
+ await createDirectory(dirPath);
40
+
41
+ await Promise.all(collectionKeys.map(key =>
42
+ createFile(path.join(dirPath, `${key}.json`), data[key] || [], { json: true })
43
+ ));
44
+
45
+ // Overwrite db.json with empty-array skeleton
46
+ const skeleton = {};
47
+ for (const key of collectionKeys) skeleton[key] = [];
48
+
49
+ await updateFile(dbFilePath, skeleton, { json: true });
50
+ }
51
+
52
+ export async function directoryToFile() {
53
+ const { rootPath } = config;
54
+ const { file } = config.orm.db;
55
+ const dbFilePath = path.resolve(`${rootPath}/${file}`);
56
+ const collectionKeys = getCollectionKeys();
57
+ const dirPath = getDirPath();
58
+
59
+ // Read each collection from the directory
60
+ const assembled = {};
61
+
62
+ await Promise.all(collectionKeys.map(async key => {
63
+ const filePath = path.join(dirPath, `${key}.json`);
64
+ assembled[key] = await readFile(filePath, { json: true });
65
+ }));
66
+
67
+ // Overwrite db.json with full assembled data
68
+ await updateFile(dbFilePath, assembled, { json: true });
69
+
70
+ // Remove the directory
71
+ await deleteDirectory(dirPath);
72
+ }
@@ -22,8 +22,8 @@ export default class ModelProperty {
22
22
  return this._value = newValue;
23
23
  }
24
24
 
25
- if (newValue === undefined || newValue === null) return;
25
+ if (newValue === undefined) return;
26
26
 
27
- this._value = Orm.instance.transforms[this.type](newValue);
27
+ this._value = newValue === null ? null : Orm.instance.transforms[this.type](newValue);
28
28
  }
29
29
  }
package/src/model.js CHANGED
@@ -1,6 +1,17 @@
1
1
  import { attr } from '@stonyx/orm';
2
2
 
3
3
  export default class Model {
4
+ /**
5
+ * Controls whether records of this model are loaded into memory on startup.
6
+ *
7
+ * - true → loaded on boot, kept in store
8
+ * - false → never cached; find() always queries MySQL (default)
9
+ *
10
+ * Override in subclass: static memory = true;
11
+ */
12
+ static memory = false;
13
+ static pluralName = undefined;
14
+
4
15
  id = attr('number');
5
16
 
6
17
  constructor(name) {
@@ -0,0 +1,28 @@
1
+ let pool = null;
2
+
3
+ export async function getPool(mysqlConfig) {
4
+ if (pool) return pool;
5
+
6
+ const mysql = await import('mysql2/promise');
7
+
8
+ pool = mysql.createPool({
9
+ host: mysqlConfig.host,
10
+ port: mysqlConfig.port,
11
+ user: mysqlConfig.user,
12
+ password: mysqlConfig.password,
13
+ database: mysqlConfig.database,
14
+ connectionLimit: mysqlConfig.connectionLimit,
15
+ waitForConnections: true,
16
+ enableKeepAlive: true,
17
+ keepAliveInitialDelay: 10000,
18
+ });
19
+
20
+ return pool;
21
+ }
22
+
23
+ export async function closePool() {
24
+ if (!pool) return;
25
+
26
+ await pool.end();
27
+ pool = null;
28
+ }