@stonyx/orm 0.2.1-beta.6 → 0.2.1-beta.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
  }
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/index.js CHANGED
@@ -15,15 +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
29
+ export { Model, View, Serializer }; // base classes
28
30
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
29
- export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
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,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';
@@ -36,6 +37,7 @@ export default class Orm {
36
37
 
37
38
  models = {};
38
39
  serializers = {};
40
+ views = {};
39
41
  transforms = { ...baseTransforms };
40
42
  warnings = new Set();
41
43
 
@@ -66,7 +68,10 @@ export default class Orm {
66
68
  // Transforms keep their original name, everything else gets converted to PascalCase with the type suffix
67
69
  const alias = type === 'Transform' ? name : `${kebabCaseToPascalCase(name)}${type}`;
68
70
 
69
- 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
+ }
70
75
 
71
76
  return this[pluralize(lowerCaseType)][alias] = exported;
72
77
  }, { ignoreAccessFailure: true, rawName: true, recursive: true, recursiveNaming: true });
@@ -75,14 +80,28 @@ export default class Orm {
75
80
  // Wait for imports before db & rest server setup
76
81
  await Promise.all(promises);
77
82
 
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
+
78
93
  // Setup event names for hooks after models are loaded
79
94
  const eventNames = [];
80
95
  const operations = ['list', 'get', 'create', 'update', 'delete'];
96
+ const viewOperations = ['list', 'get'];
81
97
  const timings = ['before', 'after'];
82
98
 
83
99
  for (const modelName of Orm.store.data.keys()) {
100
+ const isView = this.isView(modelName);
101
+ const ops = isView ? viewOperations : operations;
102
+
84
103
  for (const timing of timings) {
85
- for (const operation of operations) {
104
+ for (const operation of ops) {
86
105
  eventNames.push(`${timing}:${operation}:${modelName}`);
87
106
  }
88
107
  }
@@ -106,6 +125,17 @@ export default class Orm {
106
125
  promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
107
126
  }
108
127
 
128
+ // Wire up memory resolver so store.find() can check model memory flags
129
+ Orm.store._memoryResolver = (modelName) => {
130
+ const { modelClass } = this.getRecordClasses(modelName);
131
+ return modelClass?.memory === true;
132
+ };
133
+
134
+ // Wire up MySQL reference for on-demand queries from store.find()/findAll()
135
+ if (this.mysqlDb) {
136
+ Orm.store._mysqlDb = this.mysqlDb;
137
+ }
138
+
109
139
  Orm.ready = await Promise.all(promises);
110
140
  Orm.initialized = true;
111
141
  }
@@ -126,13 +156,27 @@ export default class Orm {
126
156
 
127
157
  getRecordClasses(modelName) {
128
158
  const modelClassPrefix = kebabCaseToPascalCase(modelName);
129
-
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
+
130
169
  return {
131
170
  modelClass: this.models[`${modelClassPrefix}Model`],
132
171
  serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
133
172
  };
134
173
  }
135
174
 
175
+ isView(modelName) {
176
+ const modelClassPrefix = kebabCaseToPascalCase(modelName);
177
+ return !!this.views[`${modelClassPrefix}View`];
178
+ }
179
+
136
180
  // Queue warnings to avoid the same error from being logged in the same iteration
137
181
  warn(message) {
138
182
  this.warnings.add(message);
@@ -14,6 +14,11 @@ export function createRecord(modelName, rawData={}, userOptions={}) {
14
14
 
15
15
  if (!initialized && !options.isDbRecord) throw new Error('ORM is not ready');
16
16
 
17
+ // Guard: read-only views cannot have records created directly
18
+ if (orm?.isView?.(modelName) && !options.isDbRecord) {
19
+ throw new Error(`Cannot create records for read-only view '${modelName}'`);
20
+ }
21
+
17
22
  const modelStore = store.get(modelName);
18
23
  const globalRelationships = relationships.get('global');
19
24
  const pendingRelationships = relationships.get('pending');
@@ -83,6 +88,12 @@ export function createRecord(modelName, rawData={}, userOptions={}) {
83
88
  export function updateRecord(record, rawData, userOptions={}) {
84
89
  if (!rawData) throw new Error('rawData must be passed in to updateRecord call');
85
90
 
91
+ // Guard: read-only views cannot be updated
92
+ const modelName = record?.__model?.__name;
93
+ if (modelName && Orm.instance?.isView?.(modelName)) {
94
+ throw new Error(`Cannot update records for read-only view '${modelName}'`);
95
+ }
96
+
86
97
  const options = { ...defaultOptions, ...userOptions, update:true };
87
98
 
88
99
  record.serialize(rawData, options);
@@ -22,8 +22,8 @@ export default class ModelProperty {
22
22
  return this._value = newValue;
23
23
  }
24
24
 
25
- if (newValue === undefined || newValue === null) return;
25
+ if (newValue === undefined) return;
26
26
 
27
- this._value = Orm.instance.transforms[this.type](newValue);
27
+ this._value = newValue === null ? null : Orm.instance.transforms[this.type](newValue);
28
28
  }
29
29
  }
package/src/model.js CHANGED
@@ -1,6 +1,17 @@
1
1
  import { attr } from '@stonyx/orm';
2
2
 
3
3
  export default class Model {
4
+ /**
5
+ * Controls whether records of this model are loaded into memory on startup.
6
+ *
7
+ * - true → loaded on boot, kept in store
8
+ * - false → never cached; find() always queries MySQL (default)
9
+ *
10
+ * Override in subclass: static memory = true;
11
+ */
12
+ static memory = false;
13
+ static pluralName = undefined;
14
+
4
15
  id = attr('number');
5
16
 
6
17
  constructor(name) {
@@ -1,4 +1,4 @@
1
- import { introspectModels, buildTableDDL, schemasToSnapshot, getTopologicalOrder } from './schema-introspector.js';
1
+ import { introspectModels, introspectViews, buildTableDDL, buildViewDDL, schemasToSnapshot, viewSchemasToSnapshot, getTopologicalOrder } from './schema-introspector.js';
2
2
  import { readFile, createFile, createDirectory, fileExists } from '@stonyx/utils/file';
3
3
  import path from 'path';
4
4
  import config from 'stonyx/config';
@@ -16,9 +16,18 @@ export async function generateMigration(description = 'migration') {
16
16
  const previousSnapshot = await loadLatestSnapshot(migrationsPath);
17
17
  const diff = diffSnapshots(previousSnapshot, currentSnapshot);
18
18
 
19
+ // Don't return early — check view changes too before deciding
19
20
  if (!diff.hasChanges) {
20
- log.db('No schema changes detected.');
21
- return null;
21
+ // Check if there are view changes before returning null
22
+ const viewSchemasPrelim = introspectViews();
23
+ const currentViewSnapshotPrelim = viewSchemasToSnapshot(viewSchemasPrelim);
24
+ const previousViewSnapshotPrelim = extractViewsFromSnapshot(previousSnapshot);
25
+ const viewDiffPrelim = diffViewSnapshots(previousViewSnapshotPrelim, currentViewSnapshotPrelim);
26
+
27
+ if (!viewDiffPrelim.hasChanges) {
28
+ log.db('No schema changes detected.');
29
+ return null;
30
+ }
22
31
  }
23
32
 
24
33
  const upStatements = [];
@@ -85,17 +94,71 @@ export async function generateMigration(description = 'migration') {
85
94
  downStatements.push(`ALTER TABLE \`${table}\` ADD FOREIGN KEY (\`${column}\`) REFERENCES \`${references.references}\`(\`${references.column}\`) ON DELETE SET NULL;`);
86
95
  }
87
96
 
97
+ // View migrations — views are created AFTER tables (dependency order)
98
+ const viewSchemas = introspectViews();
99
+ const currentViewSnapshot = viewSchemasToSnapshot(viewSchemas);
100
+ const previousViewSnapshot = extractViewsFromSnapshot(previousSnapshot);
101
+ const viewDiff = diffViewSnapshots(previousViewSnapshot, currentViewSnapshot);
102
+
103
+ if (viewDiff.hasChanges) {
104
+ upStatements.push('');
105
+ upStatements.push('-- Views');
106
+ downStatements.push('');
107
+ downStatements.push('-- Views');
108
+
109
+ // Added views
110
+ for (const name of viewDiff.addedViews) {
111
+ try {
112
+ const ddl = buildViewDDL(name, viewSchemas[name], schemas);
113
+ upStatements.push(ddl + ';');
114
+ downStatements.unshift(`DROP VIEW IF EXISTS \`${viewSchemas[name].viewName}\`;`);
115
+ } catch (error) {
116
+ upStatements.push(`-- WARNING: Could not generate DDL for view '${name}': ${error.message}`);
117
+ }
118
+ }
119
+
120
+ // Removed views
121
+ for (const name of viewDiff.removedViews) {
122
+ upStatements.push(`-- WARNING: View '${name}' was removed. Uncomment to drop view:`);
123
+ upStatements.push(`-- DROP VIEW IF EXISTS \`${previousViewSnapshot[name].viewName}\`;`);
124
+ downStatements.push(`-- Recreate view for removed view '${name}' manually if needed`);
125
+ }
126
+
127
+ // Changed views (source or aggregates changed)
128
+ for (const name of viewDiff.changedViews) {
129
+ try {
130
+ const ddl = buildViewDDL(name, viewSchemas[name], schemas);
131
+ upStatements.push(ddl + ';');
132
+ } catch (error) {
133
+ upStatements.push(`-- WARNING: Could not generate DDL for changed view '${name}': ${error.message}`);
134
+ }
135
+ }
136
+ }
137
+
138
+ const combinedHasChanges = diff.hasChanges || viewDiff.hasChanges;
139
+
140
+ if (!combinedHasChanges) {
141
+ log.db('No schema changes detected.');
142
+ return null;
143
+ }
144
+
145
+ // Merge view snapshot into the main snapshot
146
+ const combinedSnapshot = { ...currentSnapshot };
147
+ for (const [name, viewSnap] of Object.entries(currentViewSnapshot)) {
148
+ combinedSnapshot[name] = viewSnap;
149
+ }
150
+
88
151
  const sanitizedDescription = description.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, '');
89
152
  const timestamp = Math.floor(Date.now() / 1000);
90
153
  const filename = `${timestamp}_${sanitizedDescription}.sql`;
91
154
  const content = `-- UP\n${upStatements.join('\n')}\n\n-- DOWN\n${downStatements.join('\n')}\n`;
92
155
 
93
156
  await createFile(path.join(migrationsPath, filename), content);
94
- await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(currentSnapshot, null, 2));
157
+ await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(combinedSnapshot, null, 2));
95
158
 
96
159
  log.db(`Migration generated: ${filename}`);
97
160
 
98
- return { filename, content, snapshot: currentSnapshot };
161
+ return { filename, content, snapshot: combinedSnapshot };
99
162
  }
100
163
 
101
164
  export async function loadLatestSnapshot(migrationsPath) {
@@ -186,3 +249,38 @@ export function detectSchemaDrift(schemas, snapshot) {
186
249
  const current = schemasToSnapshot(schemas);
187
250
  return diffSnapshots(snapshot, current);
188
251
  }
252
+
253
+ export function extractViewsFromSnapshot(snapshot) {
254
+ const views = {};
255
+ for (const [name, entry] of Object.entries(snapshot)) {
256
+ if (entry.isView) views[name] = entry;
257
+ }
258
+ return views;
259
+ }
260
+
261
+ export function diffViewSnapshots(previous, current) {
262
+ const addedViews = [];
263
+ const removedViews = [];
264
+ const changedViews = [];
265
+
266
+ for (const name of Object.keys(current)) {
267
+ if (!previous[name]) {
268
+ addedViews.push(name);
269
+ } else if (
270
+ current[name].viewQuery !== previous[name].viewQuery ||
271
+ current[name].source !== previous[name].source
272
+ ) {
273
+ changedViews.push(name);
274
+ }
275
+ }
276
+
277
+ for (const name of Object.keys(previous)) {
278
+ if (!current[name]) {
279
+ removedViews.push(name);
280
+ }
281
+ }
282
+
283
+ const hasChanges = addedViews.length > 0 || removedViews.length > 0 || changedViews.length > 0;
284
+
285
+ return { hasChanges, addedViews, removedViews, changedViews };
286
+ }
@@ -1,12 +1,12 @@
1
1
  import { getPool, closePool } from './connection.js';
2
2
  import { ensureMigrationsTable, getAppliedMigrations, getMigrationFiles, applyMigration, parseMigrationFile } from './migration-runner.js';
3
- import { introspectModels, getTopologicalOrder, schemasToSnapshot } from './schema-introspector.js';
3
+ import { introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot } from './schema-introspector.js';
4
4
  import { loadLatestSnapshot, detectSchemaDrift } from './migration-generator.js';
5
5
  import { buildInsert, buildUpdate, buildDelete, buildSelect } from './query-builder.js';
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 { pluralize } from '../utils.js';
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';
@@ -14,10 +14,10 @@ import path from 'path';
14
14
  const defaultDeps = {
15
15
  getPool, closePool, ensureMigrationsTable, getAppliedMigrations,
16
16
  getMigrationFiles, applyMigration, parseMigrationFile,
17
- introspectModels, getTopologicalOrder, schemasToSnapshot,
17
+ introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
18
18
  loadLatestSnapshot, detectSchemaDrift,
19
19
  buildInsert, buildUpdate, buildDelete, buildSelect,
20
- createRecord, store, confirm, readFile, pluralize, config, log, path
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.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
 
@@ -137,6 +149,143 @@ export default class MysqlDB {
137
149
  }
138
150
  }
139
151
 
152
+ // Load views with memory: true
153
+ const viewSchemas = this.deps.introspectViews();
154
+
155
+ for (const [viewName, viewSchema] of Object.entries(viewSchemas)) {
156
+ const { modelClass: viewClass } = Orm.instance.getRecordClasses(viewName);
157
+ if (viewClass?.memory !== true) {
158
+ this.deps.log.db(`Skipping memory load for view '${viewName}' (memory: false)`);
159
+ continue;
160
+ }
161
+
162
+ const schema = { table: viewSchema.viewName, columns: viewSchema.columns || {}, foreignKeys: viewSchema.foreignKeys || {} };
163
+ const { sql, values } = this.deps.buildSelect(schema.table);
164
+
165
+ try {
166
+ const [rows] = await this.pool.execute(sql, values);
167
+
168
+ for (const row of rows) {
169
+ const rawData = this._rowToRawData(row, schema);
170
+ this.deps.createRecord(viewName, rawData, { isDbRecord: true, serialize: false, transform: false });
171
+ }
172
+ } catch (error) {
173
+ if (error.code === 'ER_NO_SUCH_TABLE') {
174
+ this.deps.log.db(`View '${viewSchema.viewName}' does not exist yet. Skipping load for '${viewName}'.`);
175
+ continue;
176
+ }
177
+ throw error;
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * @deprecated Use loadMemoryRecords() instead. Kept for backward compatibility.
184
+ */
185
+ async loadAllRecords() {
186
+ return this.loadMemoryRecords();
187
+ }
188
+
189
+ /**
190
+ * Find a single record by ID from MySQL.
191
+ * Does NOT cache the result in the store for memory: false models.
192
+ * @param {string} modelName
193
+ * @param {string|number} id
194
+ * @returns {Promise<Record|undefined>}
195
+ */
196
+ async findRecord(modelName, id) {
197
+ const schemas = this.deps.introspectModels();
198
+ let schema = schemas[modelName];
199
+
200
+ // Check views if not found in models
201
+ if (!schema) {
202
+ const viewSchemas = this.deps.introspectViews();
203
+ const viewSchema = viewSchemas[modelName];
204
+ if (viewSchema) {
205
+ schema = { table: viewSchema.viewName, columns: viewSchema.columns || {}, foreignKeys: viewSchema.foreignKeys || {} };
206
+ }
207
+ }
208
+
209
+ if (!schema) return undefined;
210
+
211
+ const { sql, values } = this.deps.buildSelect(schema.table, { id });
212
+
213
+ try {
214
+ const [rows] = await this.pool.execute(sql, values);
215
+
216
+ if (rows.length === 0) return undefined;
217
+
218
+ const rawData = this._rowToRawData(rows[0], schema);
219
+ const record = this.deps.createRecord(modelName, rawData, { isDbRecord: true, serialize: false, transform: false });
220
+
221
+ // Don't let memory:false records accumulate in the store
222
+ // The caller keeps the reference; the store doesn't retain it
223
+ this._evictIfNotMemory(modelName, record);
224
+
225
+ return record;
226
+ } catch (error) {
227
+ if (error.code === 'ER_NO_SUCH_TABLE') return undefined;
228
+ throw error;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Find all records of a model from MySQL, with optional conditions.
234
+ * @param {string} modelName
235
+ * @param {Object} [conditions] - Optional WHERE conditions (key-value pairs)
236
+ * @returns {Promise<Record[]>}
237
+ */
238
+ async findAll(modelName, conditions) {
239
+ const schemas = this.deps.introspectModels();
240
+ let schema = schemas[modelName];
241
+
242
+ // Check views if not found in models
243
+ if (!schema) {
244
+ const viewSchemas = this.deps.introspectViews();
245
+ const viewSchema = viewSchemas[modelName];
246
+ if (viewSchema) {
247
+ schema = { table: viewSchema.viewName, columns: viewSchema.columns || {}, foreignKeys: viewSchema.foreignKeys || {} };
248
+ }
249
+ }
250
+
251
+ if (!schema) return [];
252
+
253
+ const { sql, values } = this.deps.buildSelect(schema.table, conditions);
254
+
255
+ try {
256
+ const [rows] = await this.pool.execute(sql, values);
257
+
258
+ const records = rows.map(row => {
259
+ const rawData = this._rowToRawData(row, schema);
260
+ return this.deps.createRecord(modelName, rawData, { isDbRecord: true, serialize: false, transform: false });
261
+ });
262
+
263
+ // Don't let memory:false records accumulate in the store
264
+ for (const record of records) {
265
+ this._evictIfNotMemory(modelName, record);
266
+ }
267
+
268
+ return records;
269
+ } catch (error) {
270
+ if (error.code === 'ER_NO_SUCH_TABLE') return [];
271
+ throw error;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Remove a record from the in-memory store if its model has memory: false.
277
+ * The record object itself survives — the caller retains the reference.
278
+ * This prevents on-demand queries from leaking records into the store.
279
+ * @private
280
+ */
281
+ _evictIfNotMemory(modelName, record) {
282
+ const store = this.deps.store;
283
+
284
+ // Use the memory resolver if available (set by Orm.init)
285
+ if (store._memoryResolver && !store._memoryResolver(modelName)) {
286
+ const modelStore = store.get?.(modelName) ?? store.data?.get(modelName);
287
+ if (modelStore) modelStore.delete(record.id);
288
+ }
140
289
  }
141
290
 
142
291
  _rowToRawData(row, schema) {
@@ -175,6 +324,10 @@ export default class MysqlDB {
175
324
  }
176
325
 
177
326
  async persist(operation, modelName, context, response) {
327
+ // Views are read-only — no-op for all write operations
328
+ const Orm = (await import('@stonyx/orm')).default;
329
+ if (Orm.instance?.isView?.(modelName)) return;
330
+
178
331
  switch (operation) {
179
332
  case 'create':
180
333
  return this._persistCreate(modelName, context, response);