@stonyx/orm 0.2.1-beta.3 → 0.2.1-beta.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/.claude/index.md +2 -0
- package/.claude/usage-patterns.md +17 -0
- package/README.md +16 -0
- package/package.json +11 -7
- package/src/index.js +7 -1
- package/src/main.js +16 -1
- package/src/model-property.js +2 -2
- package/src/model.js +11 -0
- package/src/mysql/mysql-db.js +108 -6
- package/src/mysql/schema-introspector.js +6 -4
- package/src/orm-request.js +20 -20
- package/src/plural-registry.js +12 -0
- package/src/record.js +2 -2
- package/src/serializer.js +2 -2
- package/src/setup-rest-server.js +2 -2
- package/src/store.js +105 -0
package/.claude/index.md
CHANGED
|
@@ -65,6 +65,7 @@ stonyx-orm/
|
|
|
65
65
|
│ ├── migrate.js # JSON DB mode migration (file <-> directory)
|
|
66
66
|
│ ├── commands.js # CLI commands (db:migrate-*, etc.)
|
|
67
67
|
│ ├── utils.js # Pluralize wrapper for dasherized names
|
|
68
|
+
│ ├── plural-registry.js # Plural name registry (populated at init, supports Model.pluralName overrides)
|
|
68
69
|
│ ├── exports/
|
|
69
70
|
│ │ └── db.js # Convenience re-export of DB instance
|
|
70
71
|
│ └── mysql/
|
|
@@ -150,6 +151,7 @@ The ORM supports two storage modes, configured via `db.mode`:
|
|
|
150
151
|
- Models: `{PascalCase}Model` (e.g., `AnimalModel`)
|
|
151
152
|
- Serializers: `{PascalCase}Serializer` (e.g., `AnimalSerializer`)
|
|
152
153
|
- Transforms: Original filename (e.g., `animal.js`)
|
|
154
|
+
- Plural names: Auto-pluralized by default (e.g., `animal` → `animals`). Override with `static pluralName` on the model class (e.g., `static pluralName = 'people'`). All call sites use `getPluralName()` from the plural registry, **not** `pluralize()` directly.
|
|
153
155
|
|
|
154
156
|
---
|
|
155
157
|
|
|
@@ -31,6 +31,23 @@ export default class AnimalModel extends Model {
|
|
|
31
31
|
- Use `hasMany(modelName)` for one-to-many
|
|
32
32
|
- Getters work as computed properties
|
|
33
33
|
- Relationships auto-establish bidirectionally
|
|
34
|
+
- Override auto-pluralization with `static pluralName` (see [Overriding Plural Names](#overriding-plural-names))
|
|
35
|
+
|
|
36
|
+
### Overriding Plural Names
|
|
37
|
+
|
|
38
|
+
By default, model names are auto-pluralized (e.g., `animal` → `animals`) for REST routes, JSON:API URLs, and DB table names. When auto-pluralization produces the wrong result, override it with `static pluralName`:
|
|
39
|
+
|
|
40
|
+
```javascript
|
|
41
|
+
import { Model, attr } from '@stonyx/orm';
|
|
42
|
+
|
|
43
|
+
export default class PersonModel extends Model {
|
|
44
|
+
static pluralName = 'people';
|
|
45
|
+
|
|
46
|
+
name = attr('string');
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The override is picked up automatically during ORM initialization — no additional registration is needed. All internal call sites (REST routes, JSON:API type references, MySQL table names, foreign key references) use the overridden value.
|
|
34
51
|
|
|
35
52
|
## 2. Serializers (Data Transformation)
|
|
36
53
|
|
package/README.md
CHANGED
|
@@ -109,6 +109,22 @@ export default class OwnerModel extends Model {
|
|
|
109
109
|
}
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
+
### Overriding Plural Names
|
|
113
|
+
|
|
114
|
+
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`:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
import { Model, attr } from '@stonyx/orm';
|
|
118
|
+
|
|
119
|
+
export default class PersonModel extends Model {
|
|
120
|
+
static pluralName = 'people';
|
|
121
|
+
|
|
122
|
+
name = attr('string');
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The override is picked up automatically during ORM initialization. All routes, JSON:API type references, and MySQL table names will use the overridden value.
|
|
127
|
+
|
|
112
128
|
## Serializers
|
|
113
129
|
|
|
114
130
|
Based on the following sample payload structure which represents a poorly structure third-party data source:
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.2.1-beta.
|
|
7
|
+
"version": "0.2.1-beta.31",
|
|
8
8
|
"description": "",
|
|
9
9
|
"main": "src/main.js",
|
|
10
10
|
"type": "module",
|
|
@@ -33,21 +33,25 @@
|
|
|
33
33
|
},
|
|
34
34
|
"homepage": "https://github.com/abofs/stonyx-orm#readme",
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"stonyx": "0.2.3-beta.
|
|
37
|
-
"@stonyx/events": "0.1.1-beta.
|
|
38
|
-
"@stonyx/cron": "0.2.1-beta.
|
|
36
|
+
"stonyx": "0.2.3-beta.6",
|
|
37
|
+
"@stonyx/events": "0.1.1-beta.7",
|
|
38
|
+
"@stonyx/cron": "0.2.1-beta.12"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"mysql2": "^3.0.0"
|
|
41
|
+
"mysql2": "^3.0.0",
|
|
42
|
+
"@stonyx/rest-server": ">=0.2.1-beta.14"
|
|
42
43
|
},
|
|
43
44
|
"peerDependenciesMeta": {
|
|
44
45
|
"mysql2": {
|
|
45
46
|
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"@stonyx/rest-server": {
|
|
49
|
+
"optional": true
|
|
46
50
|
}
|
|
47
51
|
},
|
|
48
52
|
"devDependencies": {
|
|
49
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
50
|
-
"@stonyx/utils": "0.2.3-beta.
|
|
53
|
+
"@stonyx/rest-server": "0.2.1-beta.16",
|
|
54
|
+
"@stonyx/utils": "0.2.3-beta.5",
|
|
51
55
|
"qunit": "^2.24.1",
|
|
52
56
|
"sinon": "^21.0.0"
|
|
53
57
|
},
|
package/src/index.js
CHANGED
|
@@ -26,4 +26,10 @@ export { default } from './main.js';
|
|
|
26
26
|
export { store, relationships } from './main.js';
|
|
27
27
|
export { Model, Serializer }; // base classes
|
|
28
28
|
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
|
29
|
-
export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
|
|
29
|
+
export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
|
|
30
|
+
|
|
31
|
+
// Store API:
|
|
32
|
+
// store.get(model, id) — sync, memory-only
|
|
33
|
+
// store.find(model, id) — async, MySQL for memory:false models
|
|
34
|
+
// store.findAll(model) — async, all records
|
|
35
|
+
// store.query(model, conditions) — async, always hits MySQL
|
package/src/main.js
CHANGED
|
@@ -19,6 +19,7 @@ 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';
|
|
@@ -66,7 +67,10 @@ export default class Orm {
|
|
|
66
67
|
// Transforms keep their original name, everything else gets converted to PascalCase with the type suffix
|
|
67
68
|
const alias = type === 'Transform' ? name : `${kebabCaseToPascalCase(name)}${type}`;
|
|
68
69
|
|
|
69
|
-
if (type === 'Model')
|
|
70
|
+
if (type === 'Model') {
|
|
71
|
+
Orm.store.set(name, new Map());
|
|
72
|
+
registerPluralName(name, exported);
|
|
73
|
+
}
|
|
70
74
|
|
|
71
75
|
return this[pluralize(lowerCaseType)][alias] = exported;
|
|
72
76
|
}, { ignoreAccessFailure: true, rawName: true, recursive: true, recursiveNaming: true });
|
|
@@ -106,6 +110,17 @@ export default class Orm {
|
|
|
106
110
|
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
// Wire up memory resolver so store.find() can check model memory flags
|
|
114
|
+
Orm.store._memoryResolver = (modelName) => {
|
|
115
|
+
const { modelClass } = this.getRecordClasses(modelName);
|
|
116
|
+
return modelClass?.memory !== false;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Wire up MySQL reference for on-demand queries from store.find()/findAll()
|
|
120
|
+
if (this.mysqlDb) {
|
|
121
|
+
Orm.store._mysqlDb = this.mysqlDb;
|
|
122
|
+
}
|
|
123
|
+
|
|
109
124
|
Orm.ready = await Promise.all(promises);
|
|
110
125
|
Orm.initialized = true;
|
|
111
126
|
}
|
package/src/model-property.js
CHANGED
|
@@ -22,8 +22,8 @@ export default class ModelProperty {
|
|
|
22
22
|
return this._value = newValue;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
if (newValue === undefined
|
|
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 (default for backward compatibility)
|
|
8
|
+
* - false → never cached; find() always queries MySQL
|
|
9
|
+
*
|
|
10
|
+
* Override in subclass: static memory = false;
|
|
11
|
+
*/
|
|
12
|
+
static memory = true;
|
|
13
|
+
static pluralName = undefined;
|
|
14
|
+
|
|
4
15
|
id = attr('number');
|
|
5
16
|
|
|
6
17
|
constructor(name) {
|
package/src/mysql/mysql-db.js
CHANGED
|
@@ -6,7 +6,7 @@ import { buildInsert, buildUpdate, buildDelete, buildSelect } from './query-buil
|
|
|
6
6
|
import { createRecord, store } from '@stonyx/orm';
|
|
7
7
|
import { confirm } from '@stonyx/utils/prompt';
|
|
8
8
|
import { readFile } from '@stonyx/utils/file';
|
|
9
|
-
import {
|
|
9
|
+
import { getPluralName } from '../plural-registry.js';
|
|
10
10
|
import config from 'stonyx/config';
|
|
11
11
|
import log from 'stonyx/log';
|
|
12
12
|
import path from 'path';
|
|
@@ -17,7 +17,7 @@ const defaultDeps = {
|
|
|
17
17
|
introspectModels, getTopologicalOrder, schemasToSnapshot,
|
|
18
18
|
loadLatestSnapshot, detectSchemaDrift,
|
|
19
19
|
buildInsert, buildUpdate, buildDelete, buildSelect,
|
|
20
|
-
createRecord, store, confirm, readFile,
|
|
20
|
+
createRecord, store, confirm, readFile, getPluralName, config, log, path
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export default class MysqlDB {
|
|
@@ -33,7 +33,7 @@ export default class MysqlDB {
|
|
|
33
33
|
async init() {
|
|
34
34
|
this.pool = await this.deps.getPool(this.mysqlConfig);
|
|
35
35
|
await this.deps.ensureMigrationsTable(this.pool, this.mysqlConfig.migrationsTable);
|
|
36
|
-
await this.
|
|
36
|
+
await this.loadMemoryRecords();
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async startup() {
|
|
@@ -59,7 +59,7 @@ export default class MysqlDB {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
// Reload records after applying migrations
|
|
62
|
-
await this.
|
|
62
|
+
await this.loadMemoryRecords();
|
|
63
63
|
} else {
|
|
64
64
|
this.deps.log.warn('Skipping pending migrations. Schema may be outdated.');
|
|
65
65
|
}
|
|
@@ -80,7 +80,7 @@ export default class MysqlDB {
|
|
|
80
80
|
const { up } = this.deps.parseMigrationFile(result.content);
|
|
81
81
|
await this.deps.applyMigration(this.pool, result.filename, up, this.mysqlConfig.migrationsTable);
|
|
82
82
|
this.deps.log.db(`Applied migration: ${result.filename}`);
|
|
83
|
-
await this.
|
|
83
|
+
await this.loadMemoryRecords();
|
|
84
84
|
}
|
|
85
85
|
} else {
|
|
86
86
|
this.deps.log.warn('Skipping initial migration. Tables may not exist.');
|
|
@@ -111,11 +111,23 @@ export default class MysqlDB {
|
|
|
111
111
|
// No-op: MySQL persists data immediately via persist()
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Loads only models with memory: true into the in-memory store on startup.
|
|
116
|
+
* Models with memory: false are skipped — accessed on-demand via find()/findAll().
|
|
117
|
+
*/
|
|
118
|
+
async loadMemoryRecords() {
|
|
115
119
|
const schemas = this.deps.introspectModels();
|
|
116
120
|
const order = this.deps.getTopologicalOrder(schemas);
|
|
121
|
+
const Orm = (await import('@stonyx/orm')).default;
|
|
117
122
|
|
|
118
123
|
for (const modelName of order) {
|
|
124
|
+
// Check the model's memory flag — skip non-memory models
|
|
125
|
+
const { modelClass } = Orm.instance.getRecordClasses(modelName);
|
|
126
|
+
if (modelClass?.memory === false) {
|
|
127
|
+
this.deps.log.db(`Skipping memory load for '${modelName}' (memory: false)`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
119
131
|
const schema = schemas[modelName];
|
|
120
132
|
const { sql, values } = this.deps.buildSelect(schema.table);
|
|
121
133
|
|
|
@@ -136,7 +148,97 @@ export default class MysqlDB {
|
|
|
136
148
|
throw error;
|
|
137
149
|
}
|
|
138
150
|
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @deprecated Use loadMemoryRecords() instead. Kept for backward compatibility.
|
|
155
|
+
*/
|
|
156
|
+
async loadAllRecords() {
|
|
157
|
+
return this.loadMemoryRecords();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Find a single record by ID from MySQL.
|
|
162
|
+
* Does NOT cache the result in the store for memory: false models.
|
|
163
|
+
* @param {string} modelName
|
|
164
|
+
* @param {string|number} id
|
|
165
|
+
* @returns {Promise<Record|undefined>}
|
|
166
|
+
*/
|
|
167
|
+
async findRecord(modelName, id) {
|
|
168
|
+
const schemas = this.deps.introspectModels();
|
|
169
|
+
const schema = schemas[modelName];
|
|
170
|
+
|
|
171
|
+
if (!schema) return undefined;
|
|
172
|
+
|
|
173
|
+
const { sql, values } = this.deps.buildSelect(schema.table, { id });
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const [rows] = await this.pool.execute(sql, values);
|
|
177
|
+
|
|
178
|
+
if (rows.length === 0) return undefined;
|
|
179
|
+
|
|
180
|
+
const rawData = this._rowToRawData(rows[0], schema);
|
|
181
|
+
const record = this.deps.createRecord(modelName, rawData, { isDbRecord: true, serialize: false, transform: false });
|
|
182
|
+
|
|
183
|
+
// Don't let memory:false records accumulate in the store
|
|
184
|
+
// The caller keeps the reference; the store doesn't retain it
|
|
185
|
+
this._evictIfNotMemory(modelName, record);
|
|
186
|
+
|
|
187
|
+
return record;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error.code === 'ER_NO_SUCH_TABLE') return undefined;
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Find all records of a model from MySQL, with optional conditions.
|
|
196
|
+
* @param {string} modelName
|
|
197
|
+
* @param {Object} [conditions] - Optional WHERE conditions (key-value pairs)
|
|
198
|
+
* @returns {Promise<Record[]>}
|
|
199
|
+
*/
|
|
200
|
+
async findAll(modelName, conditions) {
|
|
201
|
+
const schemas = this.deps.introspectModels();
|
|
202
|
+
const schema = schemas[modelName];
|
|
203
|
+
|
|
204
|
+
if (!schema) return [];
|
|
205
|
+
|
|
206
|
+
const { sql, values } = this.deps.buildSelect(schema.table, conditions);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const [rows] = await this.pool.execute(sql, values);
|
|
210
|
+
|
|
211
|
+
const records = rows.map(row => {
|
|
212
|
+
const rawData = this._rowToRawData(row, schema);
|
|
213
|
+
return this.deps.createRecord(modelName, rawData, { isDbRecord: true, serialize: false, transform: false });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Don't let memory:false records accumulate in the store
|
|
217
|
+
for (const record of records) {
|
|
218
|
+
this._evictIfNotMemory(modelName, record);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return records;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error.code === 'ER_NO_SUCH_TABLE') return [];
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
139
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Remove a record from the in-memory store if its model has memory: false.
|
|
230
|
+
* The record object itself survives — the caller retains the reference.
|
|
231
|
+
* This prevents on-demand queries from leaking records into the store.
|
|
232
|
+
* @private
|
|
233
|
+
*/
|
|
234
|
+
_evictIfNotMemory(modelName, record) {
|
|
235
|
+
const store = this.deps.store;
|
|
236
|
+
|
|
237
|
+
// Use the memory resolver if available (set by Orm.init)
|
|
238
|
+
if (store._memoryResolver && !store._memoryResolver(modelName)) {
|
|
239
|
+
const modelStore = store.get?.(modelName) ?? store.data?.get(modelName);
|
|
240
|
+
if (modelStore) modelStore.delete(record.id);
|
|
241
|
+
}
|
|
140
242
|
}
|
|
141
243
|
|
|
142
244
|
_rowToRawData(row, schema) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Orm from '@stonyx/orm';
|
|
2
2
|
import { getMysqlType } from './type-map.js';
|
|
3
3
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
|
-
import {
|
|
4
|
+
import { getPluralName } from '../plural-registry.js';
|
|
5
5
|
import { dbKey } from '../db.js';
|
|
6
6
|
|
|
7
7
|
function getRelationshipInfo(property) {
|
|
@@ -51,19 +51,21 @@ export function introspectModels() {
|
|
|
51
51
|
|
|
52
52
|
// Build foreign keys from belongsTo relationships
|
|
53
53
|
for (const relName of Object.keys(relationships.belongsTo)) {
|
|
54
|
+
const modelName = camelCaseToKebabCase(relName);
|
|
54
55
|
const fkColumn = `${relName}_id`;
|
|
55
56
|
foreignKeys[fkColumn] = {
|
|
56
|
-
references:
|
|
57
|
+
references: getPluralName(modelName),
|
|
57
58
|
column: 'id',
|
|
58
59
|
};
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
schemas[name] = {
|
|
62
|
-
table:
|
|
63
|
+
table: getPluralName(name),
|
|
63
64
|
idType,
|
|
64
65
|
columns,
|
|
65
66
|
foreignKeys,
|
|
66
67
|
relationships,
|
|
68
|
+
memory: modelClass.memory !== false, // default true for backward compat
|
|
67
69
|
};
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -129,7 +131,7 @@ export function getTopologicalOrder(schemas) {
|
|
|
129
131
|
|
|
130
132
|
// Visit dependencies (belongsTo targets) first
|
|
131
133
|
for (const relName of Object.keys(schema.relationships.belongsTo)) {
|
|
132
|
-
visit(relName);
|
|
134
|
+
visit(camelCaseToKebabCase(relName));
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
order.push(name);
|
package/src/orm-request.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
2
|
import Orm, { createRecord, updateRecord, store } from '@stonyx/orm';
|
|
3
3
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
|
-
import {
|
|
4
|
+
import { getPluralName } from './plural-registry.js';
|
|
5
5
|
import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
6
6
|
import config from 'stonyx/config';
|
|
7
7
|
|
|
@@ -215,13 +215,13 @@ export default class OrmRequest extends Request {
|
|
|
215
215
|
|
|
216
216
|
this.model = model;
|
|
217
217
|
this.access = access;
|
|
218
|
-
const pluralizedModel =
|
|
218
|
+
const pluralizedModel = getPluralName(model);
|
|
219
219
|
|
|
220
220
|
const modelRelationships = getModelRelationships(model);
|
|
221
221
|
|
|
222
222
|
// Define raw handlers first
|
|
223
|
-
const getCollectionHandler = (request, { filter: accessFilter }) => {
|
|
224
|
-
const allRecords =
|
|
223
|
+
const getCollectionHandler = async (request, { filter: accessFilter }) => {
|
|
224
|
+
const allRecords = await store.findAll(model);
|
|
225
225
|
|
|
226
226
|
const queryFilters = parseFilters(request.query);
|
|
227
227
|
const queryFilterPredicate = createFilterPredicate(queryFilters);
|
|
@@ -241,8 +241,8 @@ export default class OrmRequest extends Request {
|
|
|
241
241
|
});
|
|
242
242
|
};
|
|
243
243
|
|
|
244
|
-
const getSingleHandler = (request) => {
|
|
245
|
-
const record = store.
|
|
244
|
+
const getSingleHandler = async (request) => {
|
|
245
|
+
const record = await store.find(model, getId(request.params));
|
|
246
246
|
if (!record) return 404;
|
|
247
247
|
|
|
248
248
|
const fieldsMap = parseFields(request.query);
|
|
@@ -255,7 +255,7 @@ export default class OrmRequest extends Request {
|
|
|
255
255
|
});
|
|
256
256
|
};
|
|
257
257
|
|
|
258
|
-
const createHandler = ({ body, query }) => {
|
|
258
|
+
const createHandler = async ({ body, query }) => {
|
|
259
259
|
const { type, id, attributes, relationships: rels } = body?.data || {};
|
|
260
260
|
|
|
261
261
|
if (!type) return 400; // Bad request
|
|
@@ -264,7 +264,7 @@ export default class OrmRequest extends Request {
|
|
|
264
264
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
265
265
|
|
|
266
266
|
// Check for duplicate ID
|
|
267
|
-
if (id !== undefined && store.
|
|
267
|
+
if (id !== undefined && await store.find(model, id)) return 409; // Conflict
|
|
268
268
|
|
|
269
269
|
const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
|
|
270
270
|
|
|
@@ -285,7 +285,7 @@ export default class OrmRequest extends Request {
|
|
|
285
285
|
};
|
|
286
286
|
|
|
287
287
|
const updateHandler = async ({ body, params }) => {
|
|
288
|
-
const record = store.
|
|
288
|
+
const record = await store.find(model, getId(params));
|
|
289
289
|
const { attributes, relationships: rels } = body?.data || {};
|
|
290
290
|
|
|
291
291
|
if (!attributes && !rels) return 400; // Bad request
|
|
@@ -357,7 +357,7 @@ export default class OrmRequest extends Request {
|
|
|
357
357
|
|
|
358
358
|
// Capture old state for operations that modify data
|
|
359
359
|
if (operation === 'update' || operation === 'delete') {
|
|
360
|
-
const existingRecord = store.
|
|
360
|
+
const existingRecord = await store.find(this.model, getId(request.params));
|
|
361
361
|
if (existingRecord) {
|
|
362
362
|
// Deep copy the record's data to preserve old state
|
|
363
363
|
context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
|
|
@@ -388,9 +388,9 @@ export default class OrmRequest extends Request {
|
|
|
388
388
|
context.response = response;
|
|
389
389
|
|
|
390
390
|
if (operation === 'get' && response?.data && !Array.isArray(response.data)) {
|
|
391
|
-
context.record = store.
|
|
391
|
+
context.record = await store.find(this.model, getId(request.params));
|
|
392
392
|
} else if (operation === 'list' && response?.data) {
|
|
393
|
-
context.records =
|
|
393
|
+
context.records = await store.findAll(this.model);
|
|
394
394
|
} else if (operation === 'create' && response?.data?.id) {
|
|
395
395
|
// For create, get the record from store using the ID from the response
|
|
396
396
|
const recordId = isNaN(response.data.id) ? response.data.id : parseInt(response.data.id);
|
|
@@ -424,8 +424,8 @@ export default class OrmRequest extends Request {
|
|
|
424
424
|
const dasherizedName = camelCaseToKebabCase(relationshipName);
|
|
425
425
|
|
|
426
426
|
// Related resource route: GET /:id/{relationship}
|
|
427
|
-
routes[`/:id/${dasherizedName}`] = (request) => {
|
|
428
|
-
const record = store.
|
|
427
|
+
routes[`/:id/${dasherizedName}`] = async (request) => {
|
|
428
|
+
const record = await store.find(model, getId(request.params));
|
|
429
429
|
if (!record) return 404;
|
|
430
430
|
|
|
431
431
|
const relatedData = record.__relationships[relationshipName];
|
|
@@ -447,8 +447,8 @@ export default class OrmRequest extends Request {
|
|
|
447
447
|
};
|
|
448
448
|
|
|
449
449
|
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
450
|
-
routes[`/:id/relationships/${dasherizedName}`] = (request) => {
|
|
451
|
-
const record = store.
|
|
450
|
+
routes[`/:id/relationships/${dasherizedName}`] = async (request) => {
|
|
451
|
+
const record = await store.find(model, getId(request.params));
|
|
452
452
|
if (!record) return 404;
|
|
453
453
|
|
|
454
454
|
const relatedData = record.__relationships[relationshipName];
|
|
@@ -474,8 +474,8 @@ export default class OrmRequest extends Request {
|
|
|
474
474
|
}
|
|
475
475
|
|
|
476
476
|
// Catch-all for invalid relationship names on related resource route
|
|
477
|
-
routes[`/:id/:relationship`] = (request) => {
|
|
478
|
-
const record = store.
|
|
477
|
+
routes[`/:id/:relationship`] = async (request) => {
|
|
478
|
+
const record = await store.find(model, getId(request.params));
|
|
479
479
|
if (!record) return 404;
|
|
480
480
|
|
|
481
481
|
// If we reach here, relationship doesn't exist (valid ones were registered above)
|
|
@@ -483,8 +483,8 @@ export default class OrmRequest extends Request {
|
|
|
483
483
|
};
|
|
484
484
|
|
|
485
485
|
// Catch-all for invalid relationship names on relationship linkage route
|
|
486
|
-
routes[`/:id/relationships/:relationship`] = (request) => {
|
|
487
|
-
const record = store.
|
|
486
|
+
routes[`/:id/relationships/:relationship`] = async (request) => {
|
|
487
|
+
const record = await store.find(model, getId(request.params));
|
|
488
488
|
if (!record) return 404;
|
|
489
489
|
|
|
490
490
|
return 404;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { pluralize } from './utils.js';
|
|
2
|
+
|
|
3
|
+
const registry = new Map();
|
|
4
|
+
|
|
5
|
+
export function registerPluralName(modelName, modelClass) {
|
|
6
|
+
const plural = modelClass.pluralName || pluralize(modelName);
|
|
7
|
+
registry.set(modelName, plural);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getPluralName(modelName) {
|
|
11
|
+
return registry.get(modelName) || pluralize(modelName);
|
|
12
|
+
}
|
package/src/record.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { store } from './index.js';
|
|
2
2
|
import { getComputedProperties } from "./serializer.js";
|
|
3
3
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
|
-
import {
|
|
4
|
+
import { getPluralName } from './plural-registry.js';
|
|
5
5
|
export default class Record {
|
|
6
6
|
__data = {};
|
|
7
7
|
__relationships = {};
|
|
@@ -57,7 +57,7 @@ export default class Record {
|
|
|
57
57
|
const { fields, baseUrl } = options;
|
|
58
58
|
const { __data:data } = this;
|
|
59
59
|
const modelName = this.__model.__name;
|
|
60
|
-
const pluralizedModelName =
|
|
60
|
+
const pluralizedModelName = getPluralName(modelName);
|
|
61
61
|
const recordId = data.id;
|
|
62
62
|
const relationships = {};
|
|
63
63
|
const attributes = {};
|
package/src/serializer.js
CHANGED
|
@@ -71,8 +71,8 @@ export default class Serializer {
|
|
|
71
71
|
const handler = model[key];
|
|
72
72
|
const data = query(rawData, pathPrefix, subPath);
|
|
73
73
|
|
|
74
|
-
//
|
|
75
|
-
if (
|
|
74
|
+
// Skip fields not present in the update payload (undefined = not provided)
|
|
75
|
+
if (data === undefined && options.update) continue;
|
|
76
76
|
|
|
77
77
|
// Relationship handling
|
|
78
78
|
if (typeof handler === 'function') {
|
package/src/setup-rest-server.js
CHANGED
|
@@ -5,7 +5,7 @@ import MetaRequest from './meta-request.js';
|
|
|
5
5
|
import RestServer from '@stonyx/rest-server';
|
|
6
6
|
import { forEachFileImport } from '@stonyx/utils/file';
|
|
7
7
|
import { dbKey } from './db.js';
|
|
8
|
-
import {
|
|
8
|
+
import { getPluralName } from './plural-registry.js';
|
|
9
9
|
import log from 'stonyx/log';
|
|
10
10
|
|
|
11
11
|
export default async function(route, accessPath, metaRoute) {
|
|
@@ -43,7 +43,7 @@ export default async function(route, accessPath, metaRoute) {
|
|
|
43
43
|
|
|
44
44
|
// Configure endpoints for models with access configuration
|
|
45
45
|
for (const [model, access] of Object.entries(accessFiles)) {
|
|
46
|
-
const pluralizedModel =
|
|
46
|
+
const pluralizedModel = getPluralName(model);
|
|
47
47
|
const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
|
|
48
48
|
RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
|
|
49
49
|
}
|
package/src/store.js
CHANGED
|
@@ -9,12 +9,117 @@ export default class Store {
|
|
|
9
9
|
this.data = new Map();
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Synchronous memory-only access.
|
|
14
|
+
* Returns the record if it exists in the in-memory store, undefined otherwise.
|
|
15
|
+
* Does NOT query the database. For memory:false models, use find() instead.
|
|
16
|
+
*/
|
|
12
17
|
get(key, id) {
|
|
13
18
|
if (!id) return this.data.get(key);
|
|
14
19
|
|
|
15
20
|
return this.data.get(key)?.get(id);
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Async authoritative read. Always queries MySQL for memory: false models.
|
|
25
|
+
* For memory: true models, returns from store (already loaded on boot).
|
|
26
|
+
* @param {string} modelName - The model name
|
|
27
|
+
* @param {string|number} id - The record ID
|
|
28
|
+
* @returns {Promise<Record|undefined>}
|
|
29
|
+
*/
|
|
30
|
+
async find(modelName, id) {
|
|
31
|
+
// For memory: true models, the store is authoritative
|
|
32
|
+
if (this._isMemoryModel(modelName)) {
|
|
33
|
+
return this.get(modelName, id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// For memory: false models, always query MySQL
|
|
37
|
+
if (this._mysqlDb) {
|
|
38
|
+
return this._mysqlDb.findRecord(modelName, id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Fallback to store (JSON mode or no MySQL)
|
|
42
|
+
return this.get(modelName, id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Async read for all records of a model. Always queries MySQL for memory: false models.
|
|
47
|
+
* For memory: true models, returns from store.
|
|
48
|
+
* @param {string} modelName - The model name
|
|
49
|
+
* @param {Object} [conditions] - Optional WHERE conditions
|
|
50
|
+
* @returns {Promise<Record[]>}
|
|
51
|
+
*/
|
|
52
|
+
async findAll(modelName, conditions) {
|
|
53
|
+
// For memory: true models without conditions, return from store
|
|
54
|
+
if (this._isMemoryModel(modelName) && !conditions) {
|
|
55
|
+
const modelStore = this.get(modelName);
|
|
56
|
+
return modelStore ? Array.from(modelStore.values()) : [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// For memory: false models (or filtered queries), always query MySQL
|
|
60
|
+
if (this._mysqlDb) {
|
|
61
|
+
return this._mysqlDb.findAll(modelName, conditions);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fallback to store (JSON mode) — apply conditions in-memory if provided
|
|
65
|
+
const modelStore = this.get(modelName);
|
|
66
|
+
if (!modelStore) return [];
|
|
67
|
+
|
|
68
|
+
const records = Array.from(modelStore.values());
|
|
69
|
+
|
|
70
|
+
if (!conditions || Object.keys(conditions).length === 0) return records;
|
|
71
|
+
|
|
72
|
+
return records.filter(record =>
|
|
73
|
+
Object.entries(conditions).every(([key, value]) => record.__data[key] === value)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Async query — always hits MySQL, never reads from memory cache.
|
|
79
|
+
* Use for complex queries, aggregations, or when you need guaranteed freshness.
|
|
80
|
+
* @param {string} modelName - The model name
|
|
81
|
+
* @param {Object} conditions - WHERE conditions
|
|
82
|
+
* @returns {Promise<Record[]>}
|
|
83
|
+
*/
|
|
84
|
+
async query(modelName, conditions = {}) {
|
|
85
|
+
if (this._mysqlDb) {
|
|
86
|
+
return this._mysqlDb.findAll(modelName, conditions);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fallback: filter in-memory store
|
|
90
|
+
const modelStore = this.get(modelName);
|
|
91
|
+
if (!modelStore) return [];
|
|
92
|
+
|
|
93
|
+
const records = Array.from(modelStore.values());
|
|
94
|
+
|
|
95
|
+
if (Object.keys(conditions).length === 0) return records;
|
|
96
|
+
|
|
97
|
+
return records.filter(record =>
|
|
98
|
+
Object.entries(conditions).every(([key, value]) => record.__data[key] === value)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Set by Orm during init — resolves memory flag for a model name.
|
|
104
|
+
* @type {Function|null}
|
|
105
|
+
*/
|
|
106
|
+
_memoryResolver = null;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Set by Orm during init — reference to the MysqlDB instance for on-demand queries.
|
|
110
|
+
* @type {MysqlDB|null}
|
|
111
|
+
*/
|
|
112
|
+
_mysqlDb = null;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Check if a model is configured for in-memory storage.
|
|
116
|
+
* @private
|
|
117
|
+
*/
|
|
118
|
+
_isMemoryModel(modelName) {
|
|
119
|
+
if (this._memoryResolver) return this._memoryResolver(modelName);
|
|
120
|
+
return true; // default to memory if resolver not set yet
|
|
121
|
+
}
|
|
122
|
+
|
|
18
123
|
set(key, value) {
|
|
19
124
|
this.data.set(key, value);
|
|
20
125
|
}
|