@stonyx/orm 0.2.1-beta.8 → 0.2.1-beta.81
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/README.md +64 -6
- package/config/environment.js +3 -1
- package/package.json +20 -6
- package/src/aggregates.js +93 -0
- package/src/belongs-to.js +11 -4
- package/src/cli.js +177 -0
- package/src/db.js +14 -4
- package/src/has-many.js +8 -1
- package/src/index.js +11 -2
- package/src/main.js +52 -8
- package/src/manage-record.js +16 -3
- package/src/model-property.js +2 -2
- package/src/model.js +11 -0
- package/src/mysql/migration-generator.js +103 -5
- package/src/mysql/mysql-db.js +163 -10
- package/src/mysql/schema-introspector.js +182 -15
- package/src/orm-request.js +35 -29
- package/src/plural-registry.js +12 -0
- package/src/record.js +7 -2
- package/src/serializer.js +9 -2
- package/src/setup-rest-server.js +3 -3
- package/src/standalone-db.js +176 -0
- package/src/store.js +130 -1
- package/src/view-resolver.js +183 -0
- package/src/view.js +21 -0
- package/.claude/code-style-rules.md +0 -44
- package/.claude/hooks.md +0 -250
- package/.claude/index.md +0 -279
- package/.claude/usage-patterns.md +0 -217
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/improvements.md +0 -139
- package/project-structure.md +0 -343
- package/test-events-setup.js +0 -41
- package/test-hooks-manual.js +0 -54
- package/test-hooks-with-logging.js +0 -52
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')
|
|
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
|
|
104
|
+
for (const operation of ops) {
|
|
86
105
|
eventNames.push(`${timing}:${operation}:${modelName}`);
|
|
87
106
|
}
|
|
88
107
|
}
|
|
@@ -92,9 +111,9 @@ export default class Orm {
|
|
|
92
111
|
|
|
93
112
|
if (config.orm.mysql) {
|
|
94
113
|
const { default: MysqlDB } = await import('./mysql/mysql-db.js');
|
|
95
|
-
this.
|
|
96
|
-
this.db = this.
|
|
97
|
-
promises.push(this.
|
|
114
|
+
this.sqlDb = new MysqlDB();
|
|
115
|
+
this.db = this.sqlDb;
|
|
116
|
+
promises.push(this.sqlDb.init());
|
|
98
117
|
} else if (this.options.dbType !== 'none') {
|
|
99
118
|
const db = new DB();
|
|
100
119
|
this.db = db;
|
|
@@ -106,16 +125,27 @@ 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 SQL adapter reference for on-demand queries from store.find()/findAll()
|
|
135
|
+
if (this.sqlDb) {
|
|
136
|
+
Orm.store._sqlDb = this.sqlDb;
|
|
137
|
+
}
|
|
138
|
+
|
|
109
139
|
Orm.ready = await Promise.all(promises);
|
|
110
140
|
Orm.initialized = true;
|
|
111
141
|
}
|
|
112
142
|
|
|
113
143
|
async startup() {
|
|
114
|
-
if (this.
|
|
144
|
+
if (this.sqlDb) await this.sqlDb.startup();
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
async shutdown() {
|
|
118
|
-
if (this.
|
|
148
|
+
if (this.sqlDb) await this.sqlDb.shutdown();
|
|
119
149
|
}
|
|
120
150
|
|
|
121
151
|
static get db() {
|
|
@@ -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);
|
package/src/manage-record.js
CHANGED
|
@@ -14,10 +14,17 @@ 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');
|
|
20
25
|
|
|
26
|
+
if (!modelStore) throw new Error(`Model store for '${modelName}' is not registered. Ensure the model is defined before creating records.`);
|
|
27
|
+
|
|
21
28
|
assignRecordId(modelName, rawData);
|
|
22
29
|
if (modelStore.has(rawData.id)) return modelStore.get(rawData.id);
|
|
23
30
|
|
|
@@ -83,6 +90,12 @@ export function createRecord(modelName, rawData={}, userOptions={}) {
|
|
|
83
90
|
export function updateRecord(record, rawData, userOptions={}) {
|
|
84
91
|
if (!rawData) throw new Error('rawData must be passed in to updateRecord call');
|
|
85
92
|
|
|
93
|
+
// Guard: read-only views cannot be updated
|
|
94
|
+
const modelName = record?.__model?.__name;
|
|
95
|
+
if (modelName && Orm.instance?.isView?.(modelName)) {
|
|
96
|
+
throw new Error(`Cannot update records for read-only view '${modelName}'`);
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
const options = { ...defaultOptions, ...userOptions, update:true };
|
|
87
100
|
|
|
88
101
|
record.serialize(rawData, options);
|
|
@@ -97,10 +110,10 @@ export function updateRecord(record, rawData, userOptions={}) {
|
|
|
97
110
|
function assignRecordId(modelName, rawData) {
|
|
98
111
|
if (rawData.id) return;
|
|
99
112
|
|
|
100
|
-
// In
|
|
101
|
-
if (Orm.instance?.
|
|
113
|
+
// In SQL mode with numeric IDs, defer to database auto-increment
|
|
114
|
+
if (Orm.instance?.sqlDb && !isStringIdModel(modelName)) {
|
|
102
115
|
rawData.id = `__pending_${Date.now()}_${Math.random()}`;
|
|
103
|
-
rawData.
|
|
116
|
+
rawData.__pendingSqlId = true;
|
|
104
117
|
return;
|
|
105
118
|
}
|
|
106
119
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
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(
|
|
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:
|
|
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
|
+
}
|
package/src/mysql/mysql-db.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
|
@@ -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);
|
|
@@ -199,7 +352,7 @@ export default class MysqlDB {
|
|
|
199
352
|
const insertData = this._recordToRow(record, schema);
|
|
200
353
|
|
|
201
354
|
// For auto-increment models, remove the pending ID
|
|
202
|
-
const isPendingId = record.__data.
|
|
355
|
+
const isPendingId = record.__data.__pendingSqlId;
|
|
203
356
|
|
|
204
357
|
if (isPendingId) {
|
|
205
358
|
delete insertData.id;
|
|
@@ -227,7 +380,7 @@ export default class MysqlDB {
|
|
|
227
380
|
response.data.id = realId;
|
|
228
381
|
}
|
|
229
382
|
|
|
230
|
-
delete record.__data.
|
|
383
|
+
delete record.__data.__pendingSqlId;
|
|
231
384
|
}
|
|
232
385
|
}
|
|
233
386
|
|