@stonyx/orm 0.2.1-alpha.2 → 0.2.1-alpha.21

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.
Files changed (46) hide show
  1. package/.claude/code-style-rules.md +44 -0
  2. package/.claude/hooks.md +250 -0
  3. package/.claude/index.md +292 -0
  4. package/.claude/usage-patterns.md +300 -0
  5. package/.claude/views.md +292 -0
  6. package/.github/workflows/ci.yml +5 -25
  7. package/.github/workflows/publish.yml +24 -116
  8. package/README.md +461 -15
  9. package/config/environment.js +29 -6
  10. package/improvements.md +139 -0
  11. package/package.json +24 -8
  12. package/project-structure.md +343 -0
  13. package/scripts/setup-test-db.sh +21 -0
  14. package/src/aggregates.js +93 -0
  15. package/src/belongs-to.js +4 -1
  16. package/src/commands.js +170 -0
  17. package/src/db.js +132 -6
  18. package/src/has-many.js +4 -1
  19. package/src/hooks.js +124 -0
  20. package/src/index.js +12 -2
  21. package/src/main.js +77 -4
  22. package/src/manage-record.js +30 -4
  23. package/src/migrate.js +72 -0
  24. package/src/model-property.js +2 -2
  25. package/src/model.js +11 -0
  26. package/src/mysql/connection.js +28 -0
  27. package/src/mysql/migration-generator.js +286 -0
  28. package/src/mysql/migration-runner.js +110 -0
  29. package/src/mysql/mysql-db.js +473 -0
  30. package/src/mysql/query-builder.js +64 -0
  31. package/src/mysql/schema-introspector.js +325 -0
  32. package/src/mysql/type-map.js +37 -0
  33. package/src/orm-request.js +313 -53
  34. package/src/plural-registry.js +12 -0
  35. package/src/record.js +35 -8
  36. package/src/serializer.js +9 -2
  37. package/src/setup-rest-server.js +5 -2
  38. package/src/store.js +130 -1
  39. package/src/utils.js +1 -1
  40. package/src/view-resolver.js +183 -0
  41. package/src/view.js +21 -0
  42. package/test-events-setup.js +41 -0
  43. package/test-hooks-manual.js +54 -0
  44. package/test-hooks-with-logging.js +52 -0
  45. package/.claude/project-structure.md +0 -578
  46. package/stonyx-bootstrap.cjs +0 -30
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;
@@ -60,4 +60,7 @@ export default function belongsTo(modelName) {
60
60
 
61
61
  return output;
62
62
  }
63
+
64
+ Object.defineProperty(fn, '__relatedModelName', { value: modelName });
65
+ return fn;
63
66
  }
@@ -0,0 +1,170 @@
1
+ import { fileToDirectory, directoryToFile } from './migrate.js';
2
+
3
+ export default {
4
+ 'db:migrate-to-directory': {
5
+ description: 'Migrate DB from single file to directory mode',
6
+ bootstrap: true,
7
+ run: async () => {
8
+ await fileToDirectory();
9
+ console.log('DB migration to directory mode complete.');
10
+ }
11
+ },
12
+ 'db:migrate-to-file': {
13
+ description: 'Migrate DB from directory mode to single file',
14
+ bootstrap: true,
15
+ run: async () => {
16
+ await directoryToFile();
17
+ console.log('DB migration to file mode complete.');
18
+ }
19
+ },
20
+ 'db:generate-migration': {
21
+ description: 'Generate a MySQL migration from current model schemas',
22
+ bootstrap: true,
23
+ run: async (args) => {
24
+ const description = args.join(' ') || 'migration';
25
+ const { generateMigration } = await import('./mysql/migration-generator.js');
26
+ const result = await generateMigration(description);
27
+
28
+ if (result) {
29
+ console.log(`Migration created: ${result.filename}`);
30
+ } else {
31
+ console.log('No schema changes detected. No migration generated.');
32
+ }
33
+ }
34
+ },
35
+ 'db:migrate': {
36
+ description: 'Apply pending MySQL migrations',
37
+ bootstrap: true,
38
+ run: async () => {
39
+ const config = (await import('stonyx/config')).default;
40
+ const mysqlConfig = config.orm.mysql;
41
+
42
+ if (!mysqlConfig) {
43
+ console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
44
+ process.exit(1);
45
+ }
46
+
47
+ const { getPool, closePool } = await import('./mysql/connection.js');
48
+ const { ensureMigrationsTable, getAppliedMigrations, getMigrationFiles, applyMigration, parseMigrationFile } = await import('./mysql/migration-runner.js');
49
+ const { readFile } = await import('@stonyx/utils/file');
50
+ const path = await import('path');
51
+
52
+ const pool = await getPool(mysqlConfig);
53
+ const migrationsPath = path.resolve(config.rootPath, mysqlConfig.migrationsDir);
54
+
55
+ try {
56
+ await ensureMigrationsTable(pool, mysqlConfig.migrationsTable);
57
+
58
+ const applied = await getAppliedMigrations(pool, mysqlConfig.migrationsTable);
59
+ const files = await getMigrationFiles(migrationsPath);
60
+ const pending = files.filter(f => !applied.includes(f));
61
+
62
+ if (pending.length === 0) {
63
+ console.log('No pending migrations.');
64
+ return;
65
+ }
66
+
67
+ console.log(`Applying ${pending.length} migration(s)...`);
68
+
69
+ for (const filename of pending) {
70
+ const content = await readFile(path.join(migrationsPath, filename));
71
+ const { up } = parseMigrationFile(content);
72
+
73
+ await applyMigration(pool, filename, up, mysqlConfig.migrationsTable);
74
+ console.log(` Applied: ${filename}`);
75
+ }
76
+
77
+ console.log('All migrations applied.');
78
+ } finally {
79
+ await closePool();
80
+ }
81
+ }
82
+ },
83
+ 'db:migrate:rollback': {
84
+ description: 'Rollback the most recent MySQL migration',
85
+ bootstrap: true,
86
+ run: async () => {
87
+ const config = (await import('stonyx/config')).default;
88
+ const mysqlConfig = config.orm.mysql;
89
+
90
+ if (!mysqlConfig) {
91
+ console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
92
+ process.exit(1);
93
+ }
94
+
95
+ const { getPool, closePool } = await import('./mysql/connection.js');
96
+ const { ensureMigrationsTable, getAppliedMigrations, rollbackMigration, parseMigrationFile } = await import('./mysql/migration-runner.js');
97
+ const { readFile } = await import('@stonyx/utils/file');
98
+ const path = await import('path');
99
+
100
+ const pool = await getPool(mysqlConfig);
101
+ const migrationsPath = path.resolve(config.rootPath, mysqlConfig.migrationsDir);
102
+
103
+ try {
104
+ await ensureMigrationsTable(pool, mysqlConfig.migrationsTable);
105
+
106
+ const applied = await getAppliedMigrations(pool, mysqlConfig.migrationsTable);
107
+
108
+ if (applied.length === 0) {
109
+ console.log('No migrations to rollback.');
110
+ return;
111
+ }
112
+
113
+ const lastFilename = applied[applied.length - 1];
114
+ const content = await readFile(path.join(migrationsPath, lastFilename));
115
+ const { down } = parseMigrationFile(content);
116
+
117
+ if (!down) {
118
+ console.error(`No DOWN section found in ${lastFilename}. Cannot rollback.`);
119
+ process.exit(1);
120
+ }
121
+
122
+ await rollbackMigration(pool, lastFilename, down, mysqlConfig.migrationsTable);
123
+ console.log(`Rolled back: ${lastFilename}`);
124
+ } finally {
125
+ await closePool();
126
+ }
127
+ }
128
+ },
129
+ 'db:migrate:status': {
130
+ description: 'Show status of MySQL migrations',
131
+ bootstrap: true,
132
+ run: async () => {
133
+ const config = (await import('stonyx/config')).default;
134
+ const mysqlConfig = config.orm.mysql;
135
+
136
+ if (!mysqlConfig) {
137
+ console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
138
+ process.exit(1);
139
+ }
140
+
141
+ const { getPool, closePool } = await import('./mysql/connection.js');
142
+ const { ensureMigrationsTable, getAppliedMigrations, getMigrationFiles } = await import('./mysql/migration-runner.js');
143
+ const path = await import('path');
144
+
145
+ const pool = await getPool(mysqlConfig);
146
+ const migrationsPath = path.resolve(config.rootPath, mysqlConfig.migrationsDir);
147
+
148
+ try {
149
+ await ensureMigrationsTable(pool, mysqlConfig.migrationsTable);
150
+
151
+ const applied = new Set(await getAppliedMigrations(pool, mysqlConfig.migrationsTable));
152
+ const files = await getMigrationFiles(migrationsPath);
153
+
154
+ if (files.length === 0) {
155
+ console.log('No migration files found.');
156
+ return;
157
+ }
158
+
159
+ console.log('Migration status:');
160
+
161
+ for (const filename of files) {
162
+ const status = applied.has(filename) ? 'applied' : 'pending';
163
+ console.log(` [${status}] ${filename}`);
164
+ }
165
+ } finally {
166
+ await closePool();
167
+ }
168
+ }
169
+ },
170
+ };
package/src/db.js CHANGED
@@ -18,14 +18,15 @@ import Cron from '@stonyx/cron';
18
18
  import config from 'stonyx/config';
19
19
  import log from 'stonyx/log';
20
20
  import Orm, { createRecord, store } from '@stonyx/orm';
21
- import { createFile, updateFile, readFile } from '@stonyx/utils/file';
21
+ import { createFile, createDirectory, updateFile, readFile, fileExists } from '@stonyx/utils/file';
22
+ import path from 'path';
22
23
 
23
24
  export const dbKey = '__db';
24
25
 
25
26
  export default class DB {
26
27
  constructor() {
27
28
  if (DB.instance) return DB.instance;
28
-
29
+
29
30
  DB.instance = this;
30
31
  }
31
32
 
@@ -38,12 +39,69 @@ export default class DB {
38
39
  return (await import(`${rootPath}/${schema}`)).default;
39
40
  }
40
41
 
42
+ getCollectionKeys() {
43
+ const SchemaClass = Orm.instance.models[`${dbKey}Model`];
44
+ const instance = new SchemaClass();
45
+ const keys = [];
46
+
47
+ for (const key of Object.keys(instance)) {
48
+ if (key === '__name' || key === 'id') continue;
49
+ if (typeof instance[key] === 'function') keys.push(key);
50
+ }
51
+
52
+ return keys;
53
+ }
54
+
55
+ getDirPath() {
56
+ const { rootPath } = config;
57
+ const { file, directory } = config.orm.db;
58
+ const dbDir = path.dirname(path.resolve(`${rootPath}/${file}`));
59
+
60
+ return path.join(dbDir, directory);
61
+ }
62
+
63
+ async validateMode() {
64
+ const { rootPath } = config;
65
+ const { file, mode } = config.orm.db;
66
+ const collectionKeys = this.getCollectionKeys();
67
+ const dirPath = this.getDirPath();
68
+
69
+ if (mode === 'directory') {
70
+ const dbFilePath = path.resolve(`${rootPath}/${file}`);
71
+ const exists = await fileExists(dbFilePath);
72
+
73
+ if (exists) {
74
+ const data = await readFile(dbFilePath, { json: true });
75
+ const hasData = collectionKeys.some(key => Array.isArray(data[key]) && data[key].length > 0);
76
+
77
+ if (hasData) {
78
+ log.error(`DB mode mismatch: db.json contains data but mode is set to 'directory'. Run migration first:\n\n stonyx db:migrate-to-directory\n`);
79
+ process.exit(1);
80
+ }
81
+ }
82
+ } else {
83
+ const dirExists = await fileExists(dirPath);
84
+
85
+ if (dirExists) {
86
+ const hasCollectionFiles = (await Promise.all(
87
+ collectionKeys.map(key => fileExists(path.join(dirPath, `${key}.json`)))
88
+ )).some(Boolean);
89
+
90
+ if (hasCollectionFiles) {
91
+ log.error(`DB mode mismatch: directory '${config.orm.db.directory}/' contains collection files but mode is set to 'file'. Run migration first:\n\n stonyx db:migrate-to-file\n`);
92
+ process.exit(1);
93
+ }
94
+ }
95
+ }
96
+ }
97
+
41
98
  async init() {
42
99
  const { autosave, saveInterval } = config.orm.db;
43
-
100
+
44
101
  store.set(dbKey, new Map());
45
102
  Orm.instance.models[`${dbKey}Model`] = await this.getSchema();
46
103
 
104
+ await this.validateMode();
47
105
  this.record = await this.getRecord();
48
106
 
49
107
  if (autosave !== 'true') return;
@@ -53,28 +111,96 @@ export default class DB {
53
111
 
54
112
  async create() {
55
113
  const { rootPath } = config;
56
- const { file } = config.orm.db;
114
+ const { file, mode } = config.orm.db;
115
+
116
+ if (mode === 'directory') {
117
+ const dirPath = this.getDirPath();
118
+ const collectionKeys = this.getCollectionKeys();
119
+
120
+ await createDirectory(dirPath);
121
+
122
+ await Promise.all(collectionKeys.map(key =>
123
+ createFile(path.join(dirPath, `${key}.json`), [], { json: true })
124
+ ));
125
+
126
+ // Write empty-array skeleton to db.json
127
+ const skeleton = {};
128
+ for (const key of collectionKeys) skeleton[key] = [];
129
+
130
+ await createFile(`${rootPath}/${file}`, skeleton, { json: true });
131
+
132
+ return skeleton;
133
+ }
57
134
 
58
135
  createFile(`${rootPath}/${file}`, {}, { json: true });
59
136
 
60
137
  return {};
61
138
  }
62
-
139
+
63
140
  async save() {
64
- const { file } = config.orm.db;
141
+ const { file, mode } = config.orm.db;
65
142
  const jsonData = this.record.format();
66
143
  delete jsonData.id; // Don't save id
67
144
 
145
+ if (mode === 'directory') {
146
+ const dirPath = this.getDirPath();
147
+ const collectionKeys = this.getCollectionKeys();
148
+
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
+ ));
153
+
154
+ // Write empty-array skeleton to db.json
155
+ const skeleton = {};
156
+ for (const key of collectionKeys) skeleton[key] = [];
157
+
158
+ await updateFile(`${config.rootPath}/${file}`, skeleton, { json: true });
159
+
160
+ log.db(`DB has been successfully saved to ${config.orm.db.directory}/ directory`);
161
+ return;
162
+ }
163
+
68
164
  await updateFile(`${config.rootPath}/${file}`, jsonData, { json: true });
69
165
 
70
166
  log.db(`DB has been successfully saved to ${file}`);
71
167
  }
72
168
 
73
169
  async getRecord() {
170
+ const { mode } = config.orm.db;
171
+
172
+ if (mode === 'directory') return this.getRecordFromDirectory();
173
+
174
+ return this.getRecordFromFile();
175
+ }
176
+
177
+ async getRecordFromFile() {
74
178
  const { file } = config.orm.db;
75
179
 
76
180
  const data = await readFile(file, { json: true, missingFileCallback: this.create.bind(this) });
77
181
 
78
182
  return createRecord(dbKey, data, { isDbRecord: true, serialize: false, transform: false });
79
183
  }
184
+
185
+ async getRecordFromDirectory() {
186
+ const dirPath = this.getDirPath();
187
+ const collectionKeys = this.getCollectionKeys();
188
+ const dirExists = await fileExists(dirPath);
189
+
190
+ if (!dirExists) {
191
+ const data = await this.create();
192
+ return createRecord(dbKey, data, { isDbRecord: true, serialize: false, transform: false });
193
+ }
194
+
195
+ const assembled = {};
196
+
197
+ await Promise.all(collectionKeys.map(async key => {
198
+ const filePath = path.join(dirPath, `${key}.json`);
199
+ const exists = await fileExists(filePath);
200
+
201
+ assembled[key] = exists ? await readFile(filePath, { json: true }) : [];
202
+ }));
203
+
204
+ return createRecord(dbKey, assembled, { isDbRecord: true, serialize: false, transform: false });
205
+ }
80
206
  }
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);
@@ -58,4 +58,7 @@ export default function hasMany(modelName) {
58
58
 
59
59
  return output;
60
60
  }
61
+
62
+ Object.defineProperty(fn, '__relatedModelName', { value: modelName });
63
+ return fn;
61
64
  }
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 !== false;
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);