@stonyx/orm 0.2.1-beta.2 → 0.2.1-beta.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.
@@ -1,6 +1,9 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
- import { createRecord, store } from '@stonyx/orm';
3
- import { pluralize } from '@stonyx/utils/string';
2
+ import Orm, { createRecord, updateRecord, store } from '@stonyx/orm';
3
+ import { camelCaseToKebabCase } from '@stonyx/utils/string';
4
+ import { getPluralName } from './plural-registry.js';
5
+ import { getBeforeHooks, getAfterHooks } from './hooks.js';
6
+ import config from 'stonyx/config';
4
7
 
5
8
  const methodAccessMap = {
6
9
  GET: 'read',
@@ -9,15 +12,62 @@ const methodAccessMap = {
9
12
  PATCH: 'update',
10
13
  };
11
14
 
15
+ const WRITE_OPERATIONS = new Set(['create', 'update', 'delete']);
16
+
17
+ // Helper to detect relationship type from function
18
+ function getRelationshipInfo(property) {
19
+ if (typeof property !== 'function') return null;
20
+ const fnStr = property.toString();
21
+ if (fnStr.includes(`getRelationships('belongsTo',`)) {
22
+ return { type: 'belongsTo', isArray: false };
23
+ }
24
+ if (fnStr.includes(`getRelationships('hasMany',`)) {
25
+ return { type: 'hasMany', isArray: true };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ // Helper to introspect model relationships
31
+ function getModelRelationships(modelName) {
32
+ const { modelClass } = Orm.instance.getRecordClasses(modelName);
33
+ if (!modelClass) return {};
34
+
35
+ const model = new modelClass(modelName);
36
+ const relationships = {};
37
+
38
+ for (const [key, property] of Object.entries(model)) {
39
+ if (key.startsWith('__')) continue;
40
+ const info = getRelationshipInfo(property);
41
+ if (info) {
42
+ relationships[key] = info;
43
+ }
44
+ }
45
+
46
+ return relationships;
47
+ }
48
+
49
+ // Helper to build base URL from request
50
+ function getBaseUrl(request) {
51
+ const protocol = request.protocol || 'http';
52
+ const host = request.get('host');
53
+ return `${protocol}://${host}`;
54
+ }
55
+
12
56
  function getId({ id }) {
13
57
  if (isNaN(id)) return id;
14
58
 
15
59
  return parseInt(id);
16
60
  }
17
61
 
18
- function buildResponse(data, includeParam, recordOrRecords) {
62
+ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
63
+ const { links, baseUrl } = options;
19
64
  const response = { data };
20
65
 
66
+ // Add top-level links
67
+ if (links) {
68
+ response.links = links;
69
+ }
70
+
21
71
  if (!includeParam) return response;
22
72
 
23
73
  const includes = parseInclude(includeParam);
@@ -25,7 +75,7 @@ function buildResponse(data, includeParam, recordOrRecords) {
25
75
 
26
76
  const includedRecords = collectIncludedRecords(recordOrRecords, includes);
27
77
  if (includedRecords.length > 0) {
28
- response.included = includedRecords.map(record => record.toJSON());
78
+ response.included = includedRecords.map(record => record.toJSON({ baseUrl }));
29
79
  }
30
80
 
31
81
  return response;
@@ -163,80 +213,284 @@ export default class OrmRequest extends Request {
163
213
  constructor({ model, access }) {
164
214
  super(...arguments);
165
215
 
216
+ this.model = model;
166
217
  this.access = access;
167
- const pluralizedModel = pluralize(model);
218
+ const pluralizedModel = getPluralName(model);
168
219
 
169
- this.handlers = {
170
- get: {
171
- [`/${pluralizedModel}`]: (request, { filter: accessFilter }) => {
172
- const allRecords = Array.from(store.get(model).values());
220
+ const modelRelationships = getModelRelationships(model);
173
221
 
174
- const queryFilters = parseFilters(request.query);
175
- const queryFilterPredicate = createFilterPredicate(queryFilters);
176
- const fieldsMap = parseFields(request.query);
177
- const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
222
+ // Define raw handlers first
223
+ const getCollectionHandler = async (request, { filter: accessFilter }) => {
224
+ const allRecords = await store.findAll(model);
178
225
 
179
- let recordsToReturn = allRecords;
180
- if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter);
181
- if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
226
+ const queryFilters = parseFilters(request.query);
227
+ const queryFilterPredicate = createFilterPredicate(queryFilters);
228
+ const fieldsMap = parseFields(request.query);
229
+ const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
182
230
 
183
- const data = recordsToReturn.map(record => record.toJSON({ fields: modelFields }));
184
- return buildResponse(data, request.query?.include, recordsToReturn);
185
- },
231
+ let recordsToReturn = allRecords;
232
+ if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter);
233
+ if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
186
234
 
187
- [`/${pluralizedModel}/:id`]: (request) => {
188
- const record = store.get(model, getId(request.params));
189
- if (!record) return 404;
235
+ const baseUrl = getBaseUrl(request);
236
+ const data = recordsToReturn.map(record => record.toJSON({ fields: modelFields, baseUrl }));
190
237
 
191
- const fieldsMap = parseFields(request.query);
192
- const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
238
+ return buildResponse(data, request.query?.include, recordsToReturn, {
239
+ links: { self: `${baseUrl}/${pluralizedModel}` },
240
+ baseUrl
241
+ });
242
+ };
193
243
 
194
- return buildResponse(record.toJSON({ fields: modelFields }), request.query?.include, record);
195
- }
196
- },
244
+ const getSingleHandler = async (request) => {
245
+ const record = await store.find(model, getId(request.params));
246
+ if (!record) return 404;
197
247
 
198
- patch: {
199
- [`/${pluralizedModel}/:id`]: async ({ body, params }) => {
200
- const record = store.get(model, getId(params));
201
- const { attributes } = body?.data || {};
248
+ const fieldsMap = parseFields(request.query);
249
+ const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
250
+
251
+ const baseUrl = getBaseUrl(request);
252
+ return buildResponse(record.toJSON({ fields: modelFields, baseUrl }), request.query?.include, record, {
253
+ links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
254
+ baseUrl
255
+ });
256
+ };
257
+
258
+ const createHandler = async ({ body, query }) => {
259
+ const { type, id, attributes, relationships: rels } = body?.data || {};
260
+
261
+ if (!type) return 400; // Bad request
202
262
 
203
- if (!attributes) return 400; // Bad request
263
+ const fieldsMap = parseFields(query);
264
+ const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
204
265
 
205
- // Apply updates 1 by 1 to utilize built-in transform logic, ignore id key
206
- for (const [key, value] of Object.entries(attributes)) {
207
- if (!record.hasOwnProperty(key)) continue;
208
- if (key === 'id') continue;
266
+ // Check for duplicate ID
267
+ if (id !== undefined && await store.find(model, id)) return 409; // Conflict
209
268
 
210
- record[key] = value
211
- };
269
+ const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
212
270
 
213
- return { data: record.toJSON() };
271
+ // Extract relationship IDs from JSON:API relationships object
272
+ if (rels) {
273
+ for (const [key, value] of Object.entries(rels)) {
274
+ const relData = value?.data;
275
+ if (relData && relData.id !== undefined) {
276
+ sanitizedAttributes[key] = relData.id;
277
+ }
214
278
  }
215
- },
279
+ }
216
280
 
217
- post: {
218
- [`/${pluralizedModel}`]: ({ body, query }) => {
219
- const { type, attributes } = body?.data || {};
281
+ const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
282
+ const record = createRecord(model, recordAttributes, { serialize: false });
220
283
 
221
- if (!type) return 400; // Bad request
284
+ return { data: record.toJSON({ fields: modelFields }) };
285
+ };
222
286
 
223
- const fieldsMap = parseFields(query);
224
- const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
225
- // Check for duplicate ID
226
- if (attributes?.id !== undefined && store.get(model, attributes.id)) return 409; // Conflict
287
+ const updateHandler = async ({ body, params }) => {
288
+ const record = await store.find(model, getId(params));
289
+ const { attributes, relationships: rels } = body?.data || {};
227
290
 
228
- const record = createRecord(model, attributes, { serialize: false });
291
+ if (!attributes && !rels) return 400; // Bad request
229
292
 
230
- return { data: record.toJSON({ fields: modelFields }) };
293
+ // Apply attribute updates 1 by 1 to utilize built-in transform logic, ignore id key
294
+ if (attributes) {
295
+ for (const [key, value] of Object.entries(attributes)) {
296
+ if (!record.hasOwnProperty(key)) continue;
297
+ if (key === 'id') continue;
298
+
299
+ record[key] = value
300
+ };
301
+ }
302
+
303
+ // Apply relationship updates via updateRecord to properly resolve references
304
+ if (rels) {
305
+ const relUpdates = {};
306
+ for (const [key, value] of Object.entries(rels)) {
307
+ const relData = value?.data;
308
+ if (relData && relData.id !== undefined) {
309
+ relUpdates[key] = relData.id;
310
+ }
231
311
  }
232
- },
312
+ if (Object.keys(relUpdates).length > 0) {
313
+ updateRecord(record, relUpdates);
314
+ }
315
+ }
316
+
317
+ return { data: record.toJSON() };
318
+ };
233
319
 
320
+ const deleteHandler = ({ params }) => {
321
+ store.remove(model, getId(params));
322
+ return 204;
323
+ };
324
+
325
+ // Wrap handlers with hooks
326
+ this.handlers = {
327
+ get: {
328
+ '/': this._withHooks('list', getCollectionHandler),
329
+ '/:id': this._withHooks('get', getSingleHandler),
330
+ ...this._generateRelationshipRoutes(model, pluralizedModel, modelRelationships)
331
+ },
332
+ patch: {
333
+ '/:id': this._withHooks('update', updateHandler)
334
+ },
335
+ post: {
336
+ '/': this._withHooks('create', createHandler)
337
+ },
234
338
  delete: {
235
- [`/${pluralizedModel}/:id`]: ({ params }) => {
236
- store.remove(model, getId(params));
339
+ '/:id': this._withHooks('delete', deleteHandler)
340
+ }
341
+ }
342
+ }
343
+
344
+ // Wraps a handler with before/after hook execution
345
+ _withHooks(operation, handler) {
346
+ return async (request, state) => {
347
+ // Build context object for hooks
348
+ const context = {
349
+ model: this.model,
350
+ operation,
351
+ request,
352
+ params: request.params,
353
+ body: request.body,
354
+ query: request.query,
355
+ state,
356
+ };
357
+
358
+ // Capture old state for operations that modify data
359
+ if (operation === 'update' || operation === 'delete') {
360
+ const existingRecord = await store.find(this.model, getId(request.params));
361
+ if (existingRecord) {
362
+ // Deep copy the record's data to preserve old state
363
+ context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
237
364
  }
365
+ if (operation === 'delete') {
366
+ context.recordId = getId(request.params);
367
+ }
368
+ }
369
+
370
+ // Run before hooks sequentially (can halt by returning a value)
371
+ for (const hook of getBeforeHooks(operation, this.model)) {
372
+ const result = await hook(context);
373
+ if (result !== undefined) {
374
+ // Hook returned a value - halt operation and return result
375
+ return result;
376
+ }
377
+ }
378
+
379
+ // Execute main handler
380
+ const response = await handler(request, state);
381
+
382
+ // Persist to MySQL for write operations
383
+ if (Orm.instance.mysqlDb && WRITE_OPERATIONS.has(operation)) {
384
+ await Orm.instance.mysqlDb.persist(operation, this.model, context, response);
385
+ }
386
+
387
+ // Add response and relevant records to context
388
+ context.response = response;
389
+
390
+ if (operation === 'get' && response?.data && !Array.isArray(response.data)) {
391
+ context.record = await store.find(this.model, getId(request.params));
392
+ } else if (operation === 'list' && response?.data) {
393
+ context.records = await store.findAll(this.model);
394
+ } else if (operation === 'create' && response?.data?.id) {
395
+ // For create, get the record from store using the ID from the response
396
+ const recordId = isNaN(response.data.id) ? response.data.id : parseInt(response.data.id);
397
+ context.record = store.get(this.model, recordId);
398
+ } else if (operation === 'update' && response?.data) {
399
+ context.record = store.get(this.model, getId(request.params));
400
+ } else if (operation === 'delete') {
401
+ // For delete, the record may no longer exist, but we have oldState
402
+ context.recordId = getId(request.params);
403
+ }
404
+
405
+ // Run after hooks sequentially
406
+ for (const hook of getAfterHooks(operation, this.model)) {
407
+ await hook(context);
238
408
  }
409
+
410
+ // Auto-save DB after write operations when configured
411
+ if (config.orm.db.autosave === 'onUpdate' && WRITE_OPERATIONS.has(operation)) {
412
+ await Orm.db.save();
413
+ }
414
+
415
+ return response;
416
+ };
417
+ }
418
+
419
+ _generateRelationshipRoutes(model, pluralizedModel, modelRelationships) {
420
+ const routes = {};
421
+
422
+ for (const [relationshipName, info] of Object.entries(modelRelationships)) {
423
+ // Dasherize the relationship name for URL paths (e.g., accessLinks -> access-links)
424
+ const dasherizedName = camelCaseToKebabCase(relationshipName);
425
+
426
+ // Related resource route: GET /:id/{relationship}
427
+ routes[`/:id/${dasherizedName}`] = async (request) => {
428
+ const record = await store.find(model, getId(request.params));
429
+ if (!record) return 404;
430
+
431
+ const relatedData = record.__relationships[relationshipName];
432
+ const baseUrl = getBaseUrl(request);
433
+
434
+ let data;
435
+ if (info.isArray) {
436
+ // hasMany - return array
437
+ data = (relatedData || []).map(r => r.toJSON({ baseUrl }));
438
+ } else {
439
+ // belongsTo - return single or null
440
+ data = relatedData ? relatedData.toJSON({ baseUrl }) : null;
441
+ }
442
+
443
+ return {
444
+ links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
445
+ data
446
+ };
447
+ };
448
+
449
+ // Relationship linkage route: GET /:id/relationships/{relationship}
450
+ routes[`/:id/relationships/${dasherizedName}`] = async (request) => {
451
+ const record = await store.find(model, getId(request.params));
452
+ if (!record) return 404;
453
+
454
+ const relatedData = record.__relationships[relationshipName];
455
+ const baseUrl = getBaseUrl(request);
456
+
457
+ let data;
458
+ if (info.isArray) {
459
+ // hasMany - return array of linkage objects
460
+ data = (relatedData || []).map(r => ({ type: r.__model.__name, id: r.id }));
461
+ } else {
462
+ // belongsTo - return single linkage or null
463
+ data = relatedData ? { type: relatedData.__model.__name, id: relatedData.id } : null;
464
+ }
465
+
466
+ return {
467
+ links: {
468
+ self: `${baseUrl}/${pluralizedModel}/${request.params.id}/relationships/${dasherizedName}`,
469
+ related: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}`
470
+ },
471
+ data
472
+ };
473
+ };
239
474
  }
475
+
476
+ // Catch-all for invalid relationship names on related resource route
477
+ routes[`/:id/:relationship`] = async (request) => {
478
+ const record = await store.find(model, getId(request.params));
479
+ if (!record) return 404;
480
+
481
+ // If we reach here, relationship doesn't exist (valid ones were registered above)
482
+ return 404;
483
+ };
484
+
485
+ // Catch-all for invalid relationship names on relationship linkage route
486
+ routes[`/:id/relationships/:relationship`] = async (request) => {
487
+ const record = await store.find(model, getId(request.params));
488
+ if (!record) return 404;
489
+
490
+ return 404;
491
+ };
492
+
493
+ return routes;
240
494
  }
241
495
 
242
496
  auth(request, state) {
@@ -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,5 +1,7 @@
1
1
  import { store } from './index.js';
2
2
  import { getComputedProperties } from "./serializer.js";
3
+ import { camelCaseToKebabCase } from '@stonyx/utils/string';
4
+ import { getPluralName } from './plural-registry.js';
3
5
  export default class Record {
4
6
  __data = {};
5
7
  __relationships = {};
@@ -8,6 +10,7 @@ export default class Record {
8
10
  constructor(model, serializer) {
9
11
  this.__model = model;
10
12
  this.__serializer = serializer;
13
+
11
14
  }
12
15
 
13
16
  serialize(rawData, options={}) {
@@ -51,8 +54,11 @@ export default class Record {
51
54
  toJSON(options = {}) {
52
55
  if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
53
56
 
57
+ const { fields, baseUrl } = options;
54
58
  const { __data:data } = this;
55
- const { fields } = options;
59
+ const modelName = this.__model.__name;
60
+ const pluralizedModelName = getPluralName(modelName);
61
+ const recordId = data.id;
56
62
  const relationships = {};
57
63
  const attributes = {};
58
64
 
@@ -69,19 +75,40 @@ export default class Record {
69
75
 
70
76
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
71
77
  if (fields && !fields.has(key)) continue;
72
- relationships[key] = {
73
- data: Array.isArray(childRecord)
78
+
79
+ const relationshipData = Array.isArray(childRecord)
74
80
  ? childRecord.map(r => ({ type: r.__model.__name, id: r.id }))
75
- : childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null
76
- };
81
+ : childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null;
82
+
83
+ // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
84
+ const dasherizedKey = camelCaseToKebabCase(key);
85
+
86
+ relationships[dasherizedKey] = { data: relationshipData };
87
+
88
+ // Add links to relationship if baseUrl provided
89
+ if (baseUrl) {
90
+ relationships[dasherizedKey].links = {
91
+ self: `${baseUrl}/${pluralizedModelName}/${recordId}/relationships/${dasherizedKey}`,
92
+ related: `${baseUrl}/${pluralizedModelName}/${recordId}/${dasherizedKey}`
93
+ };
94
+ }
77
95
  }
78
96
 
79
- return {
97
+ const result = {
80
98
  attributes,
81
99
  relationships,
82
- id: data.id,
83
- type: this.__model.__name,
100
+ id: recordId,
101
+ type: modelName,
84
102
  };
103
+
104
+ // Add resource links if baseUrl provided
105
+ if (baseUrl) {
106
+ result.links = {
107
+ self: `${baseUrl}/${pluralizedModelName}/${recordId}`
108
+ };
109
+ }
110
+
111
+ return result;
85
112
  }
86
113
 
87
114
  unload(options={}) {
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
- // Ignore null values on updates (TODO: What if we want it set to null?)
75
- if (data === null && options.update) continue;
74
+ // Ignore null/undefined values on updates (TODO: What if we want it set to null?)
75
+ if ((data === null || data === undefined) && options.update) continue;
76
76
 
77
77
  // Relationship handling
78
78
  if (typeof handler === 'function') {
@@ -5,6 +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 { getPluralName } from './plural-registry.js';
8
9
  import log from 'stonyx/log';
9
10
 
10
11
  export default async function(route, accessPath, metaRoute) {
@@ -42,7 +43,9 @@ export default async function(route, accessPath, metaRoute) {
42
43
 
43
44
  // Configure endpoints for models with access configuration
44
45
  for (const [model, access] of Object.entries(accessFiles)) {
45
- RestServer.instance.mountRoute(OrmRequest, { name, options: { model, access } });
46
+ const pluralizedModel = getPluralName(model);
47
+ const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
48
+ RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
46
49
  }
47
50
 
48
51
  // Mount the meta route when metaRoute config is enabled
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
  }
package/src/utils.js ADDED
@@ -0,0 +1,12 @@
1
+ import { pluralize as basePluralize } from '@stonyx/utils/string';
2
+
3
+ // Wrapper to handle dasherized model names (e.g., "access-link" → "access-links")
4
+ export function pluralize(word) {
5
+ if (word.includes('-')) {
6
+ const parts = word.split('-');
7
+ const pluralizedLast = basePluralize(parts.pop());
8
+ return [...parts, pluralizedLast].join('-');
9
+ }
10
+
11
+ return basePluralize(word);
12
+ }