@stonyx/orm 0.2.1-beta.87 → 0.2.1-beta.89
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/dist/aggregates.js +9 -6
- package/dist/db.js +13 -7
- package/dist/hooks.js +6 -2
- package/dist/main.js +3 -1
- package/dist/manage-record.js +10 -4
- package/dist/mysql/migration-generator.js +15 -6
- package/dist/mysql/mysql-db.js +24 -14
- package/dist/mysql/schema-introspector.js +11 -6
- package/dist/orm-request.js +35 -16
- package/dist/postgres/migration-generator.js +9 -5
- package/dist/postgres/postgres-db.js +14 -13
- package/dist/postgres/schema-introspector.js +11 -6
- package/dist/relationships.js +2 -0
- package/dist/setup-rest-server.js +4 -6
- package/dist/store.js +32 -17
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +5 -1
- package/dist/view-resolver.js +3 -1
- package/package.json +1 -1
- package/src/aggregates.ts +9 -7
- package/src/belongs-to.ts +1 -1
- package/src/db.ts +16 -9
- package/src/hooks.ts +4 -2
- package/src/main.ts +3 -2
- package/src/manage-record.ts +11 -6
- package/src/mysql/migration-generator.ts +12 -7
- package/src/mysql/mysql-db.ts +23 -17
- package/src/mysql/schema-introspector.ts +10 -7
- package/src/orm-request.ts +33 -18
- package/src/postgres/migration-generator.ts +7 -5
- package/src/postgres/postgres-db.ts +14 -13
- package/src/postgres/schema-introspector.ts +10 -7
- package/src/relationships.ts +3 -2
- package/src/setup-rest-server.ts +7 -10
- package/src/store.ts +30 -19
- package/src/utils.ts +7 -1
- package/src/view-resolver.ts +2 -1
|
@@ -56,20 +56,20 @@ export default class PostgresDB {
|
|
|
56
56
|
const files = await this.deps.getMigrationFiles(migrationsPath);
|
|
57
57
|
const pending = files.filter(f => !applied.includes(f));
|
|
58
58
|
if (pending.length > 0) {
|
|
59
|
-
this.deps.log.db(`${pending.length} pending migration(s) found.`);
|
|
59
|
+
this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
|
|
60
60
|
const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
|
|
61
61
|
if (shouldApply) {
|
|
62
62
|
for (const filename of pending) {
|
|
63
63
|
const content = await this.deps.readFile(this.deps.path.join(migrationsPath, filename));
|
|
64
64
|
const { up } = this.deps.parseMigrationFile(content);
|
|
65
65
|
await this.deps.applyMigration(this.requirePool(), filename, up, this.pgConfig.migrationsTable);
|
|
66
|
-
this.deps.log.db(`Applied migration: ${filename}`);
|
|
66
|
+
this.deps.log.db?.(`Applied migration: ${filename}`);
|
|
67
67
|
}
|
|
68
68
|
// Reload records after applying migrations
|
|
69
69
|
await this.loadMemoryRecords();
|
|
70
70
|
}
|
|
71
71
|
else {
|
|
72
|
-
this.deps.log.warn('Skipping pending migrations. Schema may be outdated.');
|
|
72
|
+
this.deps.log.warn?.('Skipping pending migrations. Schema may be outdated.');
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
else if (files.length === 0) {
|
|
@@ -83,12 +83,12 @@ export default class PostgresDB {
|
|
|
83
83
|
if (result) {
|
|
84
84
|
const { up } = this.deps.parseMigrationFile(result.content);
|
|
85
85
|
await this.deps.applyMigration(this.requirePool(), result.filename, up, this.pgConfig.migrationsTable);
|
|
86
|
-
this.deps.log.db(`Applied migration: ${result.filename}`);
|
|
86
|
+
this.deps.log.db?.(`Applied migration: ${result.filename}`);
|
|
87
87
|
await this.loadMemoryRecords();
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
else {
|
|
91
|
-
this.deps.log.warn('Skipping initial migration. Tables may not exist.');
|
|
91
|
+
this.deps.log.warn?.('Skipping initial migration. Tables may not exist.');
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
}
|
|
@@ -98,8 +98,8 @@ export default class PostgresDB {
|
|
|
98
98
|
if (Object.keys(snapshot).length > 0) {
|
|
99
99
|
const drift = this.deps.detectSchemaDrift(schemas, snapshot);
|
|
100
100
|
if (drift.hasChanges) {
|
|
101
|
-
this.deps.log.warn('Schema drift detected: models have changed since the last migration.');
|
|
102
|
-
this.deps.log.warn('Run `stonyx db:generate-migration` to create a new migration.');
|
|
101
|
+
this.deps.log.warn?.('Schema drift detected: models have changed since the last migration.');
|
|
102
|
+
this.deps.log.warn?.('Run `stonyx db:generate-migration` to create a new migration.');
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
}
|
|
@@ -121,7 +121,7 @@ export default class PostgresDB {
|
|
|
121
121
|
for (const modelName of order) {
|
|
122
122
|
const { modelClass } = Orm.instance.getRecordClasses(modelName);
|
|
123
123
|
if (modelClass?.memory === false) {
|
|
124
|
-
this.deps.log.db(`Skipping memory load for '${modelName}' (memory: false)`);
|
|
124
|
+
this.deps.log.db?.(`Skipping memory load for '${modelName}' (memory: false)`);
|
|
125
125
|
continue;
|
|
126
126
|
}
|
|
127
127
|
const schema = schemas[modelName];
|
|
@@ -136,7 +136,7 @@ export default class PostgresDB {
|
|
|
136
136
|
catch (error) {
|
|
137
137
|
// 42P01 = undefined_table (PG equivalent of ER_NO_SUCH_TABLE)
|
|
138
138
|
if (isDbError(error) && error.code === '42P01') {
|
|
139
|
-
this.deps.log.db(`Table '${schema.table}' does not exist yet. Skipping load for '${modelName}'.`);
|
|
139
|
+
this.deps.log.db?.(`Table '${schema.table}' does not exist yet. Skipping load for '${modelName}'.`);
|
|
140
140
|
continue;
|
|
141
141
|
}
|
|
142
142
|
throw error;
|
|
@@ -147,7 +147,7 @@ export default class PostgresDB {
|
|
|
147
147
|
for (const [viewName, viewSchema] of Object.entries(viewSchemas)) {
|
|
148
148
|
const { modelClass: viewClass } = Orm.instance.getRecordClasses(viewName);
|
|
149
149
|
if (viewClass?.memory !== true) {
|
|
150
|
-
this.deps.log.db(`Skipping memory load for view '${viewName}' (memory: false)`);
|
|
150
|
+
this.deps.log.db?.(`Skipping memory load for view '${viewName}' (memory: false)`);
|
|
151
151
|
continue;
|
|
152
152
|
}
|
|
153
153
|
const sourceIdType = schemas[viewSchema.source]?.idType || 'number';
|
|
@@ -170,7 +170,7 @@ export default class PostgresDB {
|
|
|
170
170
|
}
|
|
171
171
|
catch (error) {
|
|
172
172
|
if (isDbError(error) && error.code === '42P01') {
|
|
173
|
-
this.deps.log.db(`View '${viewSchema.viewName}' does not exist yet. Skipping load for '${viewName}'.`);
|
|
173
|
+
this.deps.log.db?.(`View '${viewSchema.viewName}' does not exist yet. Skipping load for '${viewName}'.`);
|
|
174
174
|
continue;
|
|
175
175
|
}
|
|
176
176
|
throw error;
|
|
@@ -250,11 +250,12 @@ export default class PostgresDB {
|
|
|
250
250
|
}
|
|
251
251
|
if (!schema)
|
|
252
252
|
return [];
|
|
253
|
-
const
|
|
253
|
+
const resolvedSchema = schema;
|
|
254
|
+
const { sql, values } = this.deps.buildSelect(resolvedSchema.table, conditions);
|
|
254
255
|
try {
|
|
255
256
|
const result = await this.requirePool().query(sql, values);
|
|
256
257
|
const records = result.rows.map(row => {
|
|
257
|
-
const rawData = this._rowToRawData(row,
|
|
258
|
+
const rawData = this._rowToRawData(row, resolvedSchema);
|
|
258
259
|
return this.deps.createRecord(modelName, rawData, { isDbRecord: true, serialize: false, transform: false });
|
|
259
260
|
});
|
|
260
261
|
for (const record of records) {
|
|
@@ -60,6 +60,8 @@ export function introspectModels() {
|
|
|
60
60
|
}
|
|
61
61
|
// Build foreign keys from belongsTo relationships
|
|
62
62
|
for (const [relName, targetModelName] of Object.entries(relationships.belongsTo)) {
|
|
63
|
+
if (!targetModelName)
|
|
64
|
+
continue;
|
|
63
65
|
const fkColumn = `${relName}_id`;
|
|
64
66
|
foreignKeys[fkColumn] = {
|
|
65
67
|
references: sanitizeTableName(getPluralName(targetModelName)),
|
|
@@ -139,7 +141,8 @@ export function getTopologicalOrder(schemas) {
|
|
|
139
141
|
return;
|
|
140
142
|
// Visit dependencies (belongsTo targets) first
|
|
141
143
|
for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
|
|
142
|
-
|
|
144
|
+
if (targetModelName)
|
|
145
|
+
visit(targetModelName);
|
|
143
146
|
}
|
|
144
147
|
order.push(name);
|
|
145
148
|
}
|
|
@@ -175,11 +178,13 @@ export function introspectViews() {
|
|
|
175
178
|
const relInfo = getRelationshipInfo(property);
|
|
176
179
|
if (relInfo?.type === 'belongsTo') {
|
|
177
180
|
relationships.belongsTo[key] = relInfo.modelName;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
if (relInfo.modelName) {
|
|
182
|
+
const fkColumn = `${key}_id`;
|
|
183
|
+
foreignKeys[fkColumn] = {
|
|
184
|
+
references: sanitizeTableName(getPluralName(relInfo.modelName)),
|
|
185
|
+
column: 'id',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
183
188
|
}
|
|
184
189
|
else if (relInfo?.type === 'hasMany') {
|
|
185
190
|
relationships.hasMany[key] = relInfo.modelName;
|
package/dist/relationships.js
CHANGED
|
@@ -10,6 +10,8 @@ export function getRelationships(type, sourceModel, targetModel, relationshipId)
|
|
|
10
10
|
if (!allRelationships.has(sourceModel))
|
|
11
11
|
allRelationships.set(sourceModel, new Map());
|
|
12
12
|
const modelRelationship = allRelationships.get(sourceModel);
|
|
13
|
+
if (!modelRelationship)
|
|
14
|
+
return undefined;
|
|
13
15
|
if (!modelRelationship.has(targetModel))
|
|
14
16
|
modelRelationship.set(targetModel, new Map());
|
|
15
17
|
const relationship = modelRelationship.get(targetModel);
|
|
@@ -8,7 +8,7 @@ import { dbKey } from './db.js';
|
|
|
8
8
|
import { getPluralName } from './plural-registry.js';
|
|
9
9
|
import log from 'stonyx/log';
|
|
10
10
|
export default async function (route, accessPath, metaRoute) {
|
|
11
|
-
|
|
11
|
+
const accessFiles = {};
|
|
12
12
|
try {
|
|
13
13
|
await forEachFileImport(accessPath, (accessClass) => {
|
|
14
14
|
const accessInstance = new accessClass();
|
|
@@ -32,8 +32,8 @@ export default async function (route, accessPath, metaRoute) {
|
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
34
|
catch (error) {
|
|
35
|
-
log.error(error instanceof Error ? error.message : String(error));
|
|
36
|
-
log.warn('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
|
|
35
|
+
log.error?.(error instanceof Error ? error.message : String(error));
|
|
36
|
+
log.warn?.('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
|
|
37
37
|
}
|
|
38
38
|
await waitForModule('rest-server');
|
|
39
39
|
// Remove "/" prefix and name mount point accordingly
|
|
@@ -46,9 +46,7 @@ export default async function (route, accessPath, metaRoute) {
|
|
|
46
46
|
}
|
|
47
47
|
// Mount the meta route when metaRoute config is enabled
|
|
48
48
|
if (metaRoute) {
|
|
49
|
-
log.warn('SECURITY RISK! - Meta route is enabled via metaRoute config. This feature is intended for development purposes only!');
|
|
49
|
+
log.warn?.('SECURITY RISK! - Meta route is enabled via metaRoute config. This feature is intended for development purposes only!');
|
|
50
50
|
RestServer.instance.mountRoute(MetaRequest, { name });
|
|
51
51
|
}
|
|
52
|
-
// Cleanup references
|
|
53
|
-
accessFiles = null;
|
|
54
52
|
}
|
package/dist/store.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import Orm, { relationships } from '@stonyx/orm';
|
|
2
2
|
import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry } from './relationships.js';
|
|
3
3
|
import ViewResolver from './view-resolver.js';
|
|
4
|
+
function isStoreRecord(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && '__data' in value;
|
|
6
|
+
}
|
|
4
7
|
export default class Store {
|
|
5
8
|
static instance;
|
|
6
9
|
data = new Map();
|
|
@@ -55,7 +58,7 @@ export default class Store {
|
|
|
55
58
|
const records = await resolver.resolveAll();
|
|
56
59
|
if (!conditions || Object.keys(conditions).length === 0)
|
|
57
60
|
return records;
|
|
58
|
-
return records.filter((record) => Object.entries(conditions).every(([key, value]) => record.__data[key] === value));
|
|
61
|
+
return records.filter((record) => Object.entries(conditions).every(([key, value]) => isStoreRecord(record) && record.__data[key] === value));
|
|
59
62
|
}
|
|
60
63
|
// For memory: true models without conditions, return from store
|
|
61
64
|
if (this._isMemoryModel(modelName) && !conditions) {
|
|
@@ -73,7 +76,7 @@ export default class Store {
|
|
|
73
76
|
const records = Array.from(modelStore.values());
|
|
74
77
|
if (!conditions || Object.keys(conditions).length === 0)
|
|
75
78
|
return records;
|
|
76
|
-
return records.filter((record) => Object.entries(conditions).every(([key, value]) => record.__data[key] === value));
|
|
79
|
+
return records.filter((record) => Object.entries(conditions).every(([key, value]) => isStoreRecord(record) && record.__data[key] === value));
|
|
77
80
|
}
|
|
78
81
|
/**
|
|
79
82
|
* Async query — always hits MySQL, never reads from memory cache.
|
|
@@ -90,7 +93,7 @@ export default class Store {
|
|
|
90
93
|
const records = Array.from(modelStore.values());
|
|
91
94
|
if (Object.keys(conditions).length === 0)
|
|
92
95
|
return records;
|
|
93
|
-
return records.filter((record) => Object.entries(conditions).every(([key, value]) => record.__data[key] === value));
|
|
96
|
+
return records.filter((record) => Object.entries(conditions).every(([key, value]) => isStoreRecord(record) && record.__data[key] === value));
|
|
94
97
|
}
|
|
95
98
|
/**
|
|
96
99
|
* Check if a model is configured for in-memory storage.
|
|
@@ -119,11 +122,14 @@ export default class Store {
|
|
|
119
122
|
console.warn(`[Store] Cannot unload record: model "${model}" not found in store`);
|
|
120
123
|
return;
|
|
121
124
|
}
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
if (typeof id !== 'string' && typeof id !== 'number')
|
|
126
|
+
return;
|
|
127
|
+
const raw = modelStore.get(id);
|
|
128
|
+
if (!raw || !isStoreRecord(raw)) {
|
|
124
129
|
console.warn(`[Store] Cannot unload record: ${model}:${id} not found in store`);
|
|
125
130
|
return;
|
|
126
131
|
}
|
|
132
|
+
const record = raw;
|
|
127
133
|
const { toUnload, visited } = options.includeChildren
|
|
128
134
|
? this._buildUnloadQueue(record, options)
|
|
129
135
|
: { toUnload: [{ record, modelName: model, recordId: id }], visited: new Set([`${model}:${id}`]) };
|
|
@@ -148,8 +154,11 @@ export default class Store {
|
|
|
148
154
|
this.unloadRecord(model, id, options);
|
|
149
155
|
}
|
|
150
156
|
}
|
|
151
|
-
for (const relationshipType of TYPES)
|
|
152
|
-
relationships.get(relationshipType)
|
|
157
|
+
for (const relationshipType of TYPES) {
|
|
158
|
+
const reg = relationships.get(relationshipType);
|
|
159
|
+
if (reg instanceof Map)
|
|
160
|
+
reg.delete(model);
|
|
161
|
+
}
|
|
153
162
|
}
|
|
154
163
|
_removeFromHasManyArrays(modelName, recordId, visited) {
|
|
155
164
|
const hasManyRegistry = getHasManyRegistry();
|
|
@@ -162,7 +171,7 @@ export default class Store {
|
|
|
162
171
|
// Don't modify arrays of records being deleted
|
|
163
172
|
if (visited.has(sourceKey))
|
|
164
173
|
continue;
|
|
165
|
-
const index = hasManyArray.findIndex(r => r && r.id === recordId);
|
|
174
|
+
const index = hasManyArray.findIndex(r => r && isStoreRecord(r) && r.id === recordId);
|
|
166
175
|
if (index !== -1)
|
|
167
176
|
hasManyArray.splice(index, 1);
|
|
168
177
|
}
|
|
@@ -175,16 +184,20 @@ export default class Store {
|
|
|
175
184
|
if (!targetModelMap)
|
|
176
185
|
continue;
|
|
177
186
|
for (const [sourceRecordId, belongsToRecord] of targetModelMap) {
|
|
178
|
-
if (belongsToRecord && belongsToRecord.id === recordId) {
|
|
187
|
+
if (belongsToRecord && isStoreRecord(belongsToRecord) && belongsToRecord.id === recordId) {
|
|
179
188
|
const sourceKey = `${sourceModel}:${sourceRecordId}`;
|
|
180
189
|
if (visited.has(sourceKey))
|
|
181
190
|
continue;
|
|
182
191
|
targetModelMap.set(sourceRecordId, null);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
192
|
+
if (typeof sourceRecordId !== 'string' && typeof sourceRecordId !== 'number')
|
|
193
|
+
continue;
|
|
194
|
+
const sourceRaw = this.get(sourceModel, sourceRecordId);
|
|
195
|
+
if (!sourceRaw || !isStoreRecord(sourceRaw))
|
|
196
|
+
continue;
|
|
197
|
+
if (sourceRaw.__relationships) {
|
|
198
|
+
for (const [key, value] of Object.entries(sourceRaw.__relationships)) {
|
|
199
|
+
if (value && isStoreRecord(value) && value.id === recordId) {
|
|
200
|
+
sourceRaw.__relationships[key] = null;
|
|
188
201
|
}
|
|
189
202
|
}
|
|
190
203
|
}
|
|
@@ -219,11 +232,11 @@ export default class Store {
|
|
|
219
232
|
// hasMany children - always include
|
|
220
233
|
if (Array.isArray(value)) {
|
|
221
234
|
for (const childRecord of value) {
|
|
222
|
-
if (childRecord)
|
|
223
|
-
children.push({ childRecord
|
|
235
|
+
if (childRecord && isStoreRecord(childRecord))
|
|
236
|
+
children.push({ childRecord, relationshipKey: key, type: 'hasMany' });
|
|
224
237
|
}
|
|
225
238
|
}
|
|
226
|
-
else if (value && !this._isBidirectionalRelationship(record.__model.__name, value.__model.__name)) {
|
|
239
|
+
else if (value && isStoreRecord(value) && value.__model && !this._isBidirectionalRelationship(record.__model.__name, value.__model.__name)) {
|
|
227
240
|
children.push({ childRecord: value, relationshipKey: key, type: 'belongsTo' });
|
|
228
241
|
}
|
|
229
242
|
}
|
|
@@ -245,6 +258,8 @@ export default class Store {
|
|
|
245
258
|
}];
|
|
246
259
|
while (queue.length > 0) {
|
|
247
260
|
const item = queue.shift();
|
|
261
|
+
if (!item)
|
|
262
|
+
break;
|
|
248
263
|
const key = `${item.modelName}:${item.recordId}`;
|
|
249
264
|
if (visited.has(key))
|
|
250
265
|
continue;
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { OrmRecord } from './types/orm-types.js';
|
|
1
2
|
export declare function isDbError(error: unknown): error is {
|
|
2
3
|
code: string;
|
|
3
4
|
message: string;
|
|
4
5
|
};
|
|
6
|
+
export declare function isOrmRecord(value: unknown): value is OrmRecord;
|
|
5
7
|
export declare function pluralize(word: string): string;
|
package/dist/utils.js
CHANGED
|
@@ -2,11 +2,15 @@ import { pluralize as basePluralize } from '@stonyx/utils/string';
|
|
|
2
2
|
export function isDbError(error) {
|
|
3
3
|
return typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' && 'message' in error && typeof error.message === 'string';
|
|
4
4
|
}
|
|
5
|
+
export function isOrmRecord(value) {
|
|
6
|
+
return typeof value === 'object' && value !== null && '__data' in value && '__relationships' in value;
|
|
7
|
+
}
|
|
5
8
|
// Wrapper to handle dasherized model names (e.g., "access-link" → "access-links")
|
|
6
9
|
export function pluralize(word) {
|
|
7
10
|
if (word.includes('-')) {
|
|
8
11
|
const parts = word.split('-');
|
|
9
|
-
const
|
|
12
|
+
const last = parts.pop();
|
|
13
|
+
const pluralizedLast = basePluralize(last);
|
|
10
14
|
return [...parts, pluralizedLast].join('-');
|
|
11
15
|
}
|
|
12
16
|
return basePluralize(word);
|
package/dist/view-resolver.js
CHANGED
|
@@ -101,7 +101,9 @@ export default class ViewResolver {
|
|
|
101
101
|
if (!groups.has(key)) {
|
|
102
102
|
groups.set(key, []);
|
|
103
103
|
}
|
|
104
|
-
groups.get(key)
|
|
104
|
+
const group = groups.get(key);
|
|
105
|
+
if (group)
|
|
106
|
+
group.push(record);
|
|
105
107
|
}
|
|
106
108
|
const results = [];
|
|
107
109
|
for (const [groupKey, groupRecords] of groups) {
|
package/package.json
CHANGED
package/src/aggregates.ts
CHANGED
|
@@ -27,13 +27,15 @@ export class AggregateProperty {
|
|
|
27
27
|
return 0;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
if (this.aggregateType === 'count') return relatedRecords.length;
|
|
31
|
+
|
|
32
|
+
const field = this.field;
|
|
33
|
+
if (!field) return null;
|
|
33
34
|
|
|
35
|
+
switch (this.aggregateType) {
|
|
34
36
|
case 'sum':
|
|
35
37
|
return relatedRecords.reduce((acc, record) => {
|
|
36
|
-
const val = parseFloat(record?.__data?.[
|
|
38
|
+
const val = parseFloat(record?.__data?.[field] as string ?? record?.[field] as string);
|
|
37
39
|
return acc + (isNaN(val) ? 0 : val);
|
|
38
40
|
}, 0);
|
|
39
41
|
|
|
@@ -41,7 +43,7 @@ export class AggregateProperty {
|
|
|
41
43
|
let sum = 0;
|
|
42
44
|
let count = 0;
|
|
43
45
|
for (const record of relatedRecords) {
|
|
44
|
-
const val = parseFloat(record?.__data?.[
|
|
46
|
+
const val = parseFloat(record?.__data?.[field] as string ?? record?.[field] as string);
|
|
45
47
|
if (!isNaN(val)) {
|
|
46
48
|
sum += val;
|
|
47
49
|
count++;
|
|
@@ -53,7 +55,7 @@ export class AggregateProperty {
|
|
|
53
55
|
case 'min': {
|
|
54
56
|
let min: number | null = null;
|
|
55
57
|
for (const record of relatedRecords) {
|
|
56
|
-
const val = parseFloat(record?.__data?.[
|
|
58
|
+
const val = parseFloat(record?.__data?.[field] as string ?? record?.[field] as string);
|
|
57
59
|
if (!isNaN(val) && (min === null || val < min)) min = val;
|
|
58
60
|
}
|
|
59
61
|
return min;
|
|
@@ -62,7 +64,7 @@ export class AggregateProperty {
|
|
|
62
64
|
case 'max': {
|
|
63
65
|
let max: number | null = null;
|
|
64
66
|
for (const record of relatedRecords) {
|
|
65
|
-
const val = parseFloat(record?.__data?.[
|
|
67
|
+
const val = parseFloat(record?.__data?.[field] as string ?? record?.[field] as string);
|
|
66
68
|
if (!isNaN(val) && (max === null || val > max)) max = val;
|
|
67
69
|
}
|
|
68
70
|
return max;
|
package/src/belongs-to.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { SourceRecord } from './types/orm-types.js';
|
|
|
4
4
|
|
|
5
5
|
function getOrSet<K, V>(map: Map<K, V>, key: K, defaultValue: V): V {
|
|
6
6
|
if (!map.has(key)) map.set(key, defaultValue);
|
|
7
|
-
return map.get(key)
|
|
7
|
+
return map.get(key) as V;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
interface BelongsToOptions {
|
package/src/db.ts
CHANGED
|
@@ -29,6 +29,13 @@ interface DBRecord {
|
|
|
29
29
|
[key: string]: unknown;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function asDBRecord(value: unknown): DBRecord {
|
|
33
|
+
if (typeof value !== 'object' || value === null || typeof (value as DBRecord).format !== 'function') {
|
|
34
|
+
throw new Error('createRecord did not return a valid DBRecord');
|
|
35
|
+
}
|
|
36
|
+
return value as DBRecord;
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
export default class DB {
|
|
33
40
|
static instance: DB;
|
|
34
41
|
record!: DBRecord;
|
|
@@ -49,7 +56,7 @@ export default class DB {
|
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
getCollectionKeys(): string[] {
|
|
52
|
-
const SchemaClass =
|
|
59
|
+
const SchemaClass = Orm.instance.models[`${dbKey}Model`] as new () => Record<string, unknown>;
|
|
53
60
|
const instance = new SchemaClass();
|
|
54
61
|
const keys: string[] = [];
|
|
55
62
|
|
|
@@ -84,7 +91,7 @@ export default class DB {
|
|
|
84
91
|
const hasData = collectionKeys.some(key => Array.isArray(data[key]) && data[key].length > 0);
|
|
85
92
|
|
|
86
93
|
if (hasData) {
|
|
87
|
-
log.error
|
|
94
|
+
log.error?.(`DB mode mismatch: db.json contains data but mode is set to 'directory'. Run migration first:\n\n stonyx db:migrate-to-directory\n`);
|
|
88
95
|
process.exit(1);
|
|
89
96
|
}
|
|
90
97
|
}
|
|
@@ -97,7 +104,7 @@ export default class DB {
|
|
|
97
104
|
)).some(Boolean);
|
|
98
105
|
|
|
99
106
|
if (hasCollectionFiles) {
|
|
100
|
-
log.error
|
|
107
|
+
log.error?.(`DB mode mismatch: directory '${config.orm.db.directory}/' contains collection files but mode is set to 'file'. Run migration first:\n\n stonyx db:migrate-to-file\n`);
|
|
101
108
|
process.exit(1);
|
|
102
109
|
}
|
|
103
110
|
}
|
|
@@ -108,7 +115,7 @@ export default class DB {
|
|
|
108
115
|
const { autosave, saveInterval } = config.orm.db;
|
|
109
116
|
|
|
110
117
|
store.set(dbKey, new Map());
|
|
111
|
-
|
|
118
|
+
Orm.instance.models[`${dbKey}Model`] = await this.getSchema();
|
|
112
119
|
|
|
113
120
|
await this.validateMode();
|
|
114
121
|
this.record = await this.getRecord();
|
|
@@ -176,13 +183,13 @@ export default class DB {
|
|
|
176
183
|
if (dbFileExists) await updateFile(dbFilePath, skeleton, { json: true });
|
|
177
184
|
else await createFile(dbFilePath, skeleton, { json: true });
|
|
178
185
|
|
|
179
|
-
log.db
|
|
186
|
+
log.db?.(`DB has been successfully saved to ${config.orm.db.directory}/ directory`);
|
|
180
187
|
return;
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
await updateFile(`${config.rootPath}/${file}`, jsonData, { json: true });
|
|
184
191
|
|
|
185
|
-
log.db
|
|
192
|
+
log.db?.(`DB has been successfully saved to ${file}`);
|
|
186
193
|
}
|
|
187
194
|
|
|
188
195
|
async getRecord(): Promise<DBRecord> {
|
|
@@ -198,7 +205,7 @@ export default class DB {
|
|
|
198
205
|
|
|
199
206
|
const data = await readFile(file, { json: true, missingFileCallback: this.create.bind(this) });
|
|
200
207
|
|
|
201
|
-
return createRecord(dbKey, data as Record<string, unknown>, { isDbRecord: true, serialize: false, transform: false })
|
|
208
|
+
return asDBRecord(createRecord(dbKey, data as Record<string, unknown>, { isDbRecord: true, serialize: false, transform: false }));
|
|
202
209
|
}
|
|
203
210
|
|
|
204
211
|
async getRecordFromDirectory(): Promise<DBRecord> {
|
|
@@ -208,7 +215,7 @@ export default class DB {
|
|
|
208
215
|
|
|
209
216
|
if (!dirExists) {
|
|
210
217
|
const data = await this.create();
|
|
211
|
-
return createRecord(dbKey, data, { isDbRecord: true, serialize: false, transform: false })
|
|
218
|
+
return asDBRecord(createRecord(dbKey, data, { isDbRecord: true, serialize: false, transform: false }));
|
|
212
219
|
}
|
|
213
220
|
|
|
214
221
|
const assembled: Record<string, unknown> = {};
|
|
@@ -220,6 +227,6 @@ export default class DB {
|
|
|
220
227
|
assembled[key] = exists ? await readFile(filePath, { json: true }) : [];
|
|
221
228
|
}));
|
|
222
229
|
|
|
223
|
-
return createRecord(dbKey, assembled, { isDbRecord: true, serialize: false, transform: false })
|
|
230
|
+
return asDBRecord(createRecord(dbKey, assembled, { isDbRecord: true, serialize: false, transform: false }));
|
|
224
231
|
}
|
|
225
232
|
}
|
package/src/hooks.ts
CHANGED
|
@@ -40,7 +40,8 @@ export function beforeHook(operation: string, model: string, handler: HookHandle
|
|
|
40
40
|
if (!beforeHooks.has(key)) {
|
|
41
41
|
beforeHooks.set(key, []);
|
|
42
42
|
}
|
|
43
|
-
beforeHooks.get(key)
|
|
43
|
+
const hooks = beforeHooks.get(key);
|
|
44
|
+
if (hooks) hooks.push(handler);
|
|
44
45
|
|
|
45
46
|
// Return unsubscribe function
|
|
46
47
|
return () => {
|
|
@@ -66,7 +67,8 @@ export function afterHook(operation: string, model: string, handler: HookHandler
|
|
|
66
67
|
if (!afterHooks.has(key)) {
|
|
67
68
|
afterHooks.set(key, []);
|
|
68
69
|
}
|
|
69
|
-
afterHooks.get(key)
|
|
70
|
+
const hooks = afterHooks.get(key);
|
|
71
|
+
if (hooks) hooks.push(handler);
|
|
70
72
|
|
|
71
73
|
// Return unsubscribe function
|
|
72
74
|
return () => {
|
package/src/main.ts
CHANGED
|
@@ -187,7 +187,8 @@ export default class Orm {
|
|
|
187
187
|
static get db(): OrmDB | SqlDb {
|
|
188
188
|
if (!Orm.initialized) throw new Error('ORM has not been initialized yet');
|
|
189
189
|
|
|
190
|
-
|
|
190
|
+
if (!Orm.instance.db) throw new Error('ORM database has not been initialized');
|
|
191
|
+
return Orm.instance.db;
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
getRecordClasses(modelName: string): { modelClass: unknown; serializerClass: unknown } {
|
|
@@ -218,7 +219,7 @@ export default class Orm {
|
|
|
218
219
|
this.warnings.add(message);
|
|
219
220
|
|
|
220
221
|
setTimeout(() => {
|
|
221
|
-
this.warnings.forEach(warning => log.warn
|
|
222
|
+
this.warnings.forEach(warning => log.warn?.(warning));
|
|
222
223
|
this.warnings.clear();
|
|
223
224
|
}, 0);
|
|
224
225
|
}
|
package/src/manage-record.ts
CHANGED
|
@@ -2,6 +2,7 @@ import Orm, { store } from '@stonyx/orm';
|
|
|
2
2
|
import OrmRecord from './record.js';
|
|
3
3
|
import { getGlobalRegistry, getPendingRegistry, getPendingBelongsToRegistry, getBelongsToRegistry, getHasManyRegistry } from './relationships.js';
|
|
4
4
|
import type Serializer from './serializer.js';
|
|
5
|
+
import { isOrmRecord } from './utils.js';
|
|
5
6
|
|
|
6
7
|
interface CreateRecordOptions {
|
|
7
8
|
isDbRecord?: boolean;
|
|
@@ -45,10 +46,10 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
45
46
|
assignRecordId(modelName, rawData);
|
|
46
47
|
const existingRecord = modelStore.get(rawData.id as number | string);
|
|
47
48
|
|
|
48
|
-
if (existingRecord) {
|
|
49
|
+
if (existingRecord instanceof OrmRecord) {
|
|
49
50
|
// Update the existing record with new data so the last entry wins
|
|
50
|
-
updateRecord(existingRecord
|
|
51
|
-
return existingRecord
|
|
51
|
+
updateRecord(existingRecord, rawData, { ...options, update: true });
|
|
52
|
+
return existingRecord;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
const recordClasses = orm.getRecordClasses(modelName);
|
|
@@ -77,7 +78,8 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
77
78
|
|
|
78
79
|
// Fulfill pending belongsTo relationships
|
|
79
80
|
const pendingBelongsToQueue = getPendingBelongsToRegistry();
|
|
80
|
-
const
|
|
81
|
+
const pendingBelongsToRaw = pendingBelongsToQueue.get(modelName)?.get(record.id);
|
|
82
|
+
const pendingBelongsTo = Array.isArray(pendingBelongsToRaw) ? pendingBelongsToRaw as PendingBelongsToEntry[] : undefined;
|
|
81
83
|
|
|
82
84
|
if (pendingBelongsTo) {
|
|
83
85
|
const belongsToReg = getBelongsToRegistry();
|
|
@@ -142,8 +144,11 @@ function assignRecordId(modelName: string, rawData: { [key: string]: unknown }):
|
|
|
142
144
|
return;
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
+
const storeMap = store.get(modelName);
|
|
148
|
+
if (!storeMap) throw new Error(`Cannot assign record ID: model "${modelName}" not found in store`);
|
|
149
|
+
const modelStore = Array.from(storeMap.values()).filter(isOrmRecord);
|
|
150
|
+
const lastRecord = modelStore.at(-1);
|
|
151
|
+
rawData.id = lastRecord ? (lastRecord.id as number) + 1 : 1;
|
|
147
152
|
}
|
|
148
153
|
|
|
149
154
|
function isStringIdModel(modelName: string): boolean {
|
|
@@ -51,9 +51,12 @@ interface GeneratedMigration {
|
|
|
51
51
|
type Snapshot = Record<string, SnapshotEntry & { isView?: boolean; viewName?: string; viewQuery?: string; source?: string }>;
|
|
52
52
|
|
|
53
53
|
export async function generateMigration(description: string = 'migration'): Promise<GeneratedMigration | null> {
|
|
54
|
-
const
|
|
54
|
+
const mysqlConfig = config.orm.mysql;
|
|
55
|
+
if (!mysqlConfig) throw new Error('MySQL configuration (config.orm.mysql) is required for migration generation');
|
|
56
|
+
const { migrationsDir } = mysqlConfig;
|
|
57
|
+
if (!migrationsDir) throw new Error('MySQL migrationsDir is required in config');
|
|
55
58
|
const rootPath = config.rootPath;
|
|
56
|
-
const migrationsPath = path.resolve(rootPath, migrationsDir
|
|
59
|
+
const migrationsPath = path.resolve(rootPath, migrationsDir);
|
|
57
60
|
|
|
58
61
|
await createDirectory(migrationsPath);
|
|
59
62
|
|
|
@@ -71,7 +74,7 @@ export async function generateMigration(description: string = 'migration'): Prom
|
|
|
71
74
|
const viewDiffPrelim = diffViewSnapshots(previousViewSnapshotPrelim, currentViewSnapshotPrelim);
|
|
72
75
|
|
|
73
76
|
if (!viewDiffPrelim.hasChanges) {
|
|
74
|
-
log.db
|
|
77
|
+
log.db?.('No schema changes detected.');
|
|
75
78
|
return null;
|
|
76
79
|
}
|
|
77
80
|
}
|
|
@@ -104,7 +107,8 @@ export async function generateMigration(description: string = 'migration'): Prom
|
|
|
104
107
|
|
|
105
108
|
// Removed columns
|
|
106
109
|
for (const { model, column, type } of diff.removedColumns) {
|
|
107
|
-
const table = previousSnapshot[model]
|
|
110
|
+
const table = previousSnapshot[model]?.table;
|
|
111
|
+
if (!table) throw new Error(`Missing table name in snapshot for model "${model}"`);
|
|
108
112
|
upStatements.push(`ALTER TABLE \`${table}\` DROP COLUMN \`${column}\`;`);
|
|
109
113
|
downStatements.push(`ALTER TABLE \`${table}\` ADD COLUMN \`${column}\` ${type};`);
|
|
110
114
|
}
|
|
@@ -130,7 +134,8 @@ export async function generateMigration(description: string = 'migration'): Prom
|
|
|
130
134
|
|
|
131
135
|
// Removed foreign keys
|
|
132
136
|
for (const { model, column, references } of diff.removedForeignKeys) {
|
|
133
|
-
const table = previousSnapshot[model]
|
|
137
|
+
const table = previousSnapshot[model]?.table;
|
|
138
|
+
if (!table) throw new Error(`Missing table name in snapshot for model "${model}"`);
|
|
134
139
|
// Resolve FK column type from the referenced table's PK type in previous snapshot
|
|
135
140
|
const refModel = Object.entries(previousSnapshot).find(([, s]) => s.table === references.references);
|
|
136
141
|
const fkType = refModel && refModel[1].idType === 'string' ? 'VARCHAR(255)' : 'INT';
|
|
@@ -184,7 +189,7 @@ export async function generateMigration(description: string = 'migration'): Prom
|
|
|
184
189
|
const combinedHasChanges = diff.hasChanges || viewDiff.hasChanges;
|
|
185
190
|
|
|
186
191
|
if (!combinedHasChanges) {
|
|
187
|
-
log.db
|
|
192
|
+
log.db?.('No schema changes detected.');
|
|
188
193
|
return null;
|
|
189
194
|
}
|
|
190
195
|
|
|
@@ -202,7 +207,7 @@ export async function generateMigration(description: string = 'migration'): Prom
|
|
|
202
207
|
await createFile(path.join(migrationsPath, filename), content);
|
|
203
208
|
await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(combinedSnapshot, null, 2));
|
|
204
209
|
|
|
205
|
-
log.db
|
|
210
|
+
log.db?.(`Migration generated: ${filename}`);
|
|
206
211
|
|
|
207
212
|
return { filename, content, snapshot: combinedSnapshot };
|
|
208
213
|
}
|