@stonyx/orm 0.2.1-beta.16 → 0.2.1-beta.17

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.2.1-beta.16",
7
+ "version": "0.2.1-beta.17",
8
8
  "description": "",
9
9
  "main": "src/main.js",
10
10
  "type": "module",
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
@@ -106,6 +106,17 @@ export default class Orm {
106
106
  promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
107
107
  }
108
108
 
109
+ // Wire up memory resolver so store.find() can check model memory flags
110
+ Orm.store._memoryResolver = (modelName) => {
111
+ const { modelClass } = this.getRecordClasses(modelName);
112
+ return modelClass?.memory !== false;
113
+ };
114
+
115
+ // Wire up MySQL reference for on-demand queries from store.find()/findAll()
116
+ if (this.mysqlDb) {
117
+ Orm.store._mysqlDb = this.mysqlDb;
118
+ }
119
+
109
120
  Orm.ready = await Promise.all(promises);
110
121
  Orm.initialized = true;
111
122
  }
package/src/model.js CHANGED
@@ -1,6 +1,16 @@
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
+
4
14
  id = attr('number');
5
15
 
6
16
  constructor(name) {
@@ -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.loadAllRecords();
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.loadAllRecords();
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.loadAllRecords();
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
- async loadAllRecords() {
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) {
@@ -64,6 +64,7 @@ export function introspectModels() {
64
64
  columns,
65
65
  foreignKeys,
66
66
  relationships,
67
+ memory: modelClass.memory !== false, // default true for backward compat
67
68
  };
68
69
  }
69
70
 
@@ -220,8 +220,8 @@ export default class OrmRequest extends Request {
220
220
  const modelRelationships = getModelRelationships(model);
221
221
 
222
222
  // Define raw handlers first
223
- const getCollectionHandler = (request, { filter: accessFilter }) => {
224
- const allRecords = Array.from(store.get(model).values());
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.get(model, getId(request.params));
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.get(model, id)) return 409; // Conflict
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.get(model, getId(params));
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.get(this.model, getId(request.params));
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.get(this.model, getId(request.params));
391
+ context.record = await store.find(this.model, getId(request.params));
392
392
  } else if (operation === 'list' && response?.data) {
393
- context.records = Array.from(store.get(this.model).values());
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.get(model, getId(request.params));
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.get(model, getId(request.params));
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.get(model, getId(request.params));
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.get(model, getId(request.params));
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;
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
  }