@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
package/src/mysql/mysql-db.ts
CHANGED
|
@@ -89,7 +89,9 @@ export default class MysqlDB {
|
|
|
89
89
|
|
|
90
90
|
this.deps = { ...defaultDeps, ...deps } as MysqlDBDeps;
|
|
91
91
|
this.pool = null;
|
|
92
|
-
|
|
92
|
+
const mysqlConfig = this.deps.config.orm.mysql;
|
|
93
|
+
if (!mysqlConfig) throw new Error('MySQL configuration (config.orm.mysql) is required');
|
|
94
|
+
this.mysqlConfig = mysqlConfig;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
private requirePool(): Pool {
|
|
@@ -104,7 +106,8 @@ export default class MysqlDB {
|
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
async startup(): Promise<void> {
|
|
107
|
-
|
|
109
|
+
if (!this.mysqlConfig.migrationsDir) throw new Error('MySQL migrationsDir is required in config');
|
|
110
|
+
const migrationsPath = this.deps.path.resolve(this.deps.config.rootPath, this.mysqlConfig.migrationsDir);
|
|
108
111
|
|
|
109
112
|
// Check for pending migrations
|
|
110
113
|
const applied = await this.deps.getAppliedMigrations(this.requirePool(), this.mysqlConfig.migrationsTable);
|
|
@@ -112,7 +115,7 @@ export default class MysqlDB {
|
|
|
112
115
|
const pending = files.filter(f => !applied.includes(f));
|
|
113
116
|
|
|
114
117
|
if (pending.length > 0) {
|
|
115
|
-
this.deps.log.db
|
|
118
|
+
this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
|
|
116
119
|
|
|
117
120
|
const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
|
|
118
121
|
|
|
@@ -122,13 +125,13 @@ export default class MysqlDB {
|
|
|
122
125
|
const { up } = this.deps.parseMigrationFile(content);
|
|
123
126
|
|
|
124
127
|
await this.deps.applyMigration(this.requirePool(), filename, up, this.mysqlConfig.migrationsTable);
|
|
125
|
-
this.deps.log.db
|
|
128
|
+
this.deps.log.db?.(`Applied migration: ${filename}`);
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
// Reload records after applying migrations
|
|
129
132
|
await this.loadMemoryRecords();
|
|
130
133
|
} else {
|
|
131
|
-
this.deps.log.warn
|
|
134
|
+
this.deps.log.warn?.('Skipping pending migrations. Schema may be outdated.');
|
|
132
135
|
}
|
|
133
136
|
} else if (files.length === 0) {
|
|
134
137
|
const schemas = this.deps.introspectModels();
|
|
@@ -146,25 +149,26 @@ export default class MysqlDB {
|
|
|
146
149
|
if (result) {
|
|
147
150
|
const { up } = this.deps.parseMigrationFile(result.content);
|
|
148
151
|
await this.deps.applyMigration(this.requirePool(), result.filename, up, this.mysqlConfig.migrationsTable);
|
|
149
|
-
this.deps.log.db
|
|
152
|
+
this.deps.log.db?.(`Applied migration: ${result.filename}`);
|
|
150
153
|
await this.loadMemoryRecords();
|
|
151
154
|
}
|
|
152
155
|
} else {
|
|
153
|
-
this.deps.log.warn
|
|
156
|
+
this.deps.log.warn?.('Skipping initial migration. Tables may not exist.');
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
// Check for schema drift
|
|
159
162
|
const schemas = this.deps.introspectModels();
|
|
160
|
-
|
|
163
|
+
if (!this.mysqlConfig.migrationsDir) throw new Error('MySQL migrationsDir is required in config');
|
|
164
|
+
const snapshot = await this.deps.loadLatestSnapshot(this.deps.path.resolve(this.deps.config.rootPath, this.mysqlConfig.migrationsDir)) as Record<string, SnapshotEntry>;
|
|
161
165
|
|
|
162
166
|
if (Object.keys(snapshot).length > 0) {
|
|
163
167
|
const drift = this.deps.detectSchemaDrift(schemas, snapshot);
|
|
164
168
|
|
|
165
169
|
if (drift.hasChanges) {
|
|
166
|
-
this.deps.log.warn
|
|
167
|
-
this.deps.log.warn
|
|
170
|
+
this.deps.log.warn?.('Schema drift detected: models have changed since the last migration.');
|
|
171
|
+
this.deps.log.warn?.('Run `stonyx db:generate-migration` to create a new migration.');
|
|
168
172
|
}
|
|
169
173
|
}
|
|
170
174
|
}
|
|
@@ -191,7 +195,7 @@ export default class MysqlDB {
|
|
|
191
195
|
// Check the model's memory flag — skip non-memory models
|
|
192
196
|
const { modelClass } = Orm.instance.getRecordClasses(modelName) as { modelClass?: { memory?: boolean } };
|
|
193
197
|
if (modelClass?.memory === false) {
|
|
194
|
-
this.deps.log.db
|
|
198
|
+
this.deps.log.db?.(`Skipping memory load for '${modelName}' (memory: false)`);
|
|
195
199
|
continue;
|
|
196
200
|
}
|
|
197
201
|
|
|
@@ -209,7 +213,7 @@ export default class MysqlDB {
|
|
|
209
213
|
} catch (error) {
|
|
210
214
|
// Table may not exist yet (pre-migration) — skip gracefully
|
|
211
215
|
if (isDbError(error) && error.code === 'ER_NO_SUCH_TABLE') {
|
|
212
|
-
this.deps.log.db
|
|
216
|
+
this.deps.log.db?.(`Table '${schema.table}' does not exist yet. Skipping load for '${modelName}'.`);
|
|
213
217
|
continue;
|
|
214
218
|
}
|
|
215
219
|
|
|
@@ -223,7 +227,7 @@ export default class MysqlDB {
|
|
|
223
227
|
for (const [viewName, viewSchema] of Object.entries(viewSchemas)) {
|
|
224
228
|
const { modelClass: viewClass } = Orm.instance.getRecordClasses(viewName) as { modelClass?: { memory?: boolean } };
|
|
225
229
|
if (viewClass?.memory !== true) {
|
|
226
|
-
this.deps.log.db
|
|
230
|
+
this.deps.log.db?.(`Skipping memory load for view '${viewName}' (memory: false)`);
|
|
227
231
|
continue;
|
|
228
232
|
}
|
|
229
233
|
|
|
@@ -240,7 +244,7 @@ export default class MysqlDB {
|
|
|
240
244
|
}
|
|
241
245
|
} catch (error) {
|
|
242
246
|
if (isDbError(error) && error.code === 'ER_NO_SUCH_TABLE') {
|
|
243
|
-
this.deps.log.db
|
|
247
|
+
this.deps.log.db?.(`View '${viewSchema.viewName}' does not exist yet. Skipping load for '${viewName}'.`);
|
|
244
248
|
continue;
|
|
245
249
|
}
|
|
246
250
|
throw error;
|
|
@@ -314,14 +318,15 @@ export default class MysqlDB {
|
|
|
314
318
|
|
|
315
319
|
if (!schema) return [];
|
|
316
320
|
|
|
317
|
-
const
|
|
321
|
+
const resolvedSchema = schema;
|
|
322
|
+
const { sql, values } = this.deps.buildSelect(resolvedSchema.table, conditions);
|
|
318
323
|
|
|
319
324
|
try {
|
|
320
325
|
const result = await this.requirePool().execute(sql, values);
|
|
321
326
|
const rows = result[0] as Record<string, unknown>[];
|
|
322
327
|
|
|
323
328
|
const records = rows.map(row => {
|
|
324
|
-
const rawData = this._rowToRawData(row,
|
|
329
|
+
const rawData = this._rowToRawData(row, resolvedSchema);
|
|
325
330
|
return this.deps.createRecord(modelName, rawData, { isDbRecord: true, serialize: false, transform: false }) as unknown as OrmRecord;
|
|
326
331
|
});
|
|
327
332
|
|
|
@@ -432,7 +437,8 @@ export default class MysqlDB {
|
|
|
432
437
|
if (isPendingId && result.insertId) {
|
|
433
438
|
const pendingId = record.id;
|
|
434
439
|
const realId = result.insertId;
|
|
435
|
-
const modelStore = this.deps.store.get(modelName)
|
|
440
|
+
const modelStore = this.deps.store.get(modelName);
|
|
441
|
+
if (!modelStore) throw new Error(`Model "${modelName}" not found in store during ID re-key`);
|
|
436
442
|
|
|
437
443
|
modelStore.delete(pendingId as number | string);
|
|
438
444
|
record.__data.id = realId;
|
|
@@ -74,9 +74,10 @@ export function introspectModels(): Record<string, ModelSchema> {
|
|
|
74
74
|
|
|
75
75
|
// Build foreign keys from belongsTo relationships
|
|
76
76
|
for (const [relName, targetModelName] of Object.entries(relationships.belongsTo)) {
|
|
77
|
+
if (!targetModelName) continue;
|
|
77
78
|
const fkColumn = `${relName}_id`;
|
|
78
79
|
foreignKeys[fkColumn] = {
|
|
79
|
-
references: sanitizeTableName(getPluralName(targetModelName
|
|
80
|
+
references: sanitizeTableName(getPluralName(targetModelName)),
|
|
80
81
|
column: 'id',
|
|
81
82
|
};
|
|
82
83
|
}
|
|
@@ -155,7 +156,7 @@ export function getTopologicalOrder(schemas: Record<string, ModelSchema>): strin
|
|
|
155
156
|
|
|
156
157
|
// Visit dependencies (belongsTo targets) first
|
|
157
158
|
for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
|
|
158
|
-
visit(targetModelName
|
|
159
|
+
if (targetModelName) visit(targetModelName);
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
order.push(name);
|
|
@@ -199,11 +200,13 @@ export function introspectViews(): Record<string, ViewSchema> {
|
|
|
199
200
|
|
|
200
201
|
if (relInfo?.type === 'belongsTo') {
|
|
201
202
|
relationships.belongsTo[key] = relInfo.modelName;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
203
|
+
if (relInfo.modelName) {
|
|
204
|
+
const fkColumn = `${key}_id`;
|
|
205
|
+
foreignKeys[fkColumn] = {
|
|
206
|
+
references: sanitizeTableName(getPluralName(relInfo.modelName)),
|
|
207
|
+
column: 'id',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
207
210
|
} else if (relInfo?.type === 'hasMany') {
|
|
208
211
|
relationships.hasMany[key] = relInfo.modelName;
|
|
209
212
|
} else if (property instanceof ModelProperty) {
|
package/src/orm-request.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { getPluralName } from './plural-registry.js';
|
|
|
5
5
|
import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
6
6
|
import config from 'stonyx/config';
|
|
7
7
|
import type { OrmRecord } from './types/orm-types.js';
|
|
8
|
+
import { isOrmRecord } from './utils.js';
|
|
8
9
|
|
|
9
10
|
interface OrmRequest$ extends Request {
|
|
10
11
|
protocol?: string;
|
|
@@ -74,7 +75,7 @@ function getRelationshipInfo(property: unknown): RelationshipInfo | null {
|
|
|
74
75
|
|
|
75
76
|
// Helper to introspect model relationships
|
|
76
77
|
function getModelRelationships(modelName: string): { [key: string]: RelationshipInfo } {
|
|
77
|
-
const { modelClass } =
|
|
78
|
+
const { modelClass } = Orm.instance.getRecordClasses(modelName);
|
|
78
79
|
if (!modelClass) return {};
|
|
79
80
|
|
|
80
81
|
const model = new (modelClass as new (name: string) => { [key: string]: unknown })(modelName);
|
|
@@ -127,7 +128,7 @@ function buildResponse(
|
|
|
127
128
|
|
|
128
129
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
129
130
|
if (includedRecords.length > 0) {
|
|
130
|
-
response.included = includedRecords.map(record => record.toJSON
|
|
131
|
+
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
return response;
|
|
@@ -157,13 +158,14 @@ function traverseIncludePath(
|
|
|
157
158
|
|
|
158
159
|
// Handle both belongsTo (single) and hasMany (array)
|
|
159
160
|
const recordsToProcess: OrmRecord[] = Array.isArray(relatedRecords)
|
|
160
|
-
? relatedRecords
|
|
161
|
-
: [relatedRecords
|
|
161
|
+
? relatedRecords.filter(isOrmRecord)
|
|
162
|
+
: isOrmRecord(relatedRecords) ? [relatedRecords] : [];
|
|
162
163
|
|
|
163
164
|
for (const relatedRecord of recordsToProcess) {
|
|
164
165
|
if (!relatedRecord) continue;
|
|
165
166
|
|
|
166
|
-
|
|
167
|
+
if (!relatedRecord.__model) continue;
|
|
168
|
+
const type = relatedRecord.__model.__name;
|
|
167
169
|
const id = relatedRecord.id as string | number;
|
|
168
170
|
|
|
169
171
|
// Initialize Set for this type if needed
|
|
@@ -280,7 +282,7 @@ export default class OrmRequest extends Request {
|
|
|
280
282
|
|
|
281
283
|
// Define raw handlers first
|
|
282
284
|
const getCollectionHandler: HandlerFn = async (request, { filter: accessFilter }) => {
|
|
283
|
-
const allRecords = await store.findAll(model)
|
|
285
|
+
const allRecords = (await store.findAll(model)).filter(isOrmRecord);
|
|
284
286
|
|
|
285
287
|
const queryFilters = parseFilters(request.query);
|
|
286
288
|
const queryFilterPredicate = createFilterPredicate(queryFilters);
|
|
@@ -292,7 +294,7 @@ export default class OrmRequest extends Request {
|
|
|
292
294
|
if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
|
|
293
295
|
|
|
294
296
|
const baseUrl = getBaseUrl(request);
|
|
295
|
-
const data = recordsToReturn.map(record => record.toJSON
|
|
297
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
|
|
296
298
|
|
|
297
299
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
298
300
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
@@ -308,7 +310,7 @@ export default class OrmRequest extends Request {
|
|
|
308
310
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
309
311
|
|
|
310
312
|
const baseUrl = getBaseUrl(request);
|
|
311
|
-
return buildResponse(record.toJSON
|
|
313
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
|
|
312
314
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
313
315
|
baseUrl
|
|
314
316
|
});
|
|
@@ -343,13 +345,17 @@ export default class OrmRequest extends Request {
|
|
|
343
345
|
}
|
|
344
346
|
|
|
345
347
|
const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
|
|
346
|
-
const
|
|
348
|
+
const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false });
|
|
349
|
+
const record = isOrmRecord(created) ? created : null;
|
|
350
|
+
if (!record) return 500;
|
|
347
351
|
|
|
348
|
-
return { data: record.toJSON
|
|
352
|
+
return { data: record.toJSON?.({ fields: modelFields }) };
|
|
349
353
|
};
|
|
350
354
|
|
|
351
355
|
const updateHandler: HandlerFn = async ({ body, params }) => {
|
|
352
|
-
const
|
|
356
|
+
const found = await store.find(model, getId(params));
|
|
357
|
+
if (!found || !isOrmRecord(found)) return 404;
|
|
358
|
+
const record = found;
|
|
353
359
|
const { attributes, relationships: rels } = (body?.data || {}) as {
|
|
354
360
|
attributes?: { [key: string]: unknown };
|
|
355
361
|
relationships?: { [key: string]: { data?: { id?: string | number } } };
|
|
@@ -381,7 +387,7 @@ export default class OrmRequest extends Request {
|
|
|
381
387
|
}
|
|
382
388
|
}
|
|
383
389
|
|
|
384
|
-
return { data: record.toJSON
|
|
390
|
+
return { data: record.toJSON?.() };
|
|
385
391
|
};
|
|
386
392
|
|
|
387
393
|
const deleteHandler: HandlerFn = ({ params }) => {
|
|
@@ -453,8 +459,9 @@ export default class OrmRequest extends Request {
|
|
|
453
459
|
const response = await handler(request, state);
|
|
454
460
|
|
|
455
461
|
// Persist to SQL database for write operations
|
|
456
|
-
|
|
457
|
-
|
|
462
|
+
const sqlDb = Orm.instance.sqlDb;
|
|
463
|
+
if (sqlDb && WRITE_OPERATIONS.has(operation)) {
|
|
464
|
+
await sqlDb.persist(operation, this.model, context, response);
|
|
458
465
|
}
|
|
459
466
|
|
|
460
467
|
// Add response and relevant records to context
|
|
@@ -512,10 +519,11 @@ export default class OrmRequest extends Request {
|
|
|
512
519
|
let data: unknown;
|
|
513
520
|
if (info.isArray) {
|
|
514
521
|
// hasMany - return array
|
|
515
|
-
|
|
522
|
+
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
523
|
+
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
516
524
|
} else {
|
|
517
525
|
// belongsTo - return single or null
|
|
518
|
-
data = relatedData ?
|
|
526
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
519
527
|
}
|
|
520
528
|
|
|
521
529
|
return {
|
|
@@ -535,10 +543,17 @@ export default class OrmRequest extends Request {
|
|
|
535
543
|
let data: unknown;
|
|
536
544
|
if (info.isArray) {
|
|
537
545
|
// hasMany - return array of linkage objects
|
|
538
|
-
|
|
546
|
+
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
547
|
+
data = related
|
|
548
|
+
.filter((r): r is OrmRecord & { __model: { __name: string } } => Boolean(r.__model))
|
|
549
|
+
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
539
550
|
} else {
|
|
540
551
|
// belongsTo - return single linkage or null
|
|
541
|
-
|
|
552
|
+
if (isOrmRecord(relatedData) && relatedData.__model) {
|
|
553
|
+
data = { type: relatedData.__model.__name, id: relatedData.id };
|
|
554
|
+
} else {
|
|
555
|
+
data = null;
|
|
556
|
+
}
|
|
542
557
|
}
|
|
543
558
|
|
|
544
559
|
return {
|
|
@@ -68,7 +68,7 @@ export async function generateMigration(description: string = 'migration', confi
|
|
|
68
68
|
const viewDiffPrelim = diffViewSnapshots(previousViewSnapshotPrelim, currentViewSnapshotPrelim);
|
|
69
69
|
|
|
70
70
|
if (!viewDiffPrelim.hasChanges) {
|
|
71
|
-
log.db
|
|
71
|
+
log.db?.('No schema changes detected.');
|
|
72
72
|
return null;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -118,7 +118,8 @@ export async function generateMigration(description: string = 'migration', confi
|
|
|
118
118
|
|
|
119
119
|
// Removed columns
|
|
120
120
|
for (const { model, column, type } of diff.removedColumns) {
|
|
121
|
-
const table = previousSnapshot[model]
|
|
121
|
+
const table = previousSnapshot[model]?.table;
|
|
122
|
+
if (!table) throw new Error(`Missing table name in snapshot for model "${model}"`);
|
|
122
123
|
upStatements.push(`ALTER TABLE "${table}" DROP COLUMN "${column}";`);
|
|
123
124
|
downStatements.push(`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type};`);
|
|
124
125
|
}
|
|
@@ -144,7 +145,8 @@ export async function generateMigration(description: string = 'migration', confi
|
|
|
144
145
|
|
|
145
146
|
// Removed foreign keys
|
|
146
147
|
for (const { model, column, references } of diff.removedForeignKeys) {
|
|
147
|
-
const table = previousSnapshot[model]
|
|
148
|
+
const table = previousSnapshot[model]?.table;
|
|
149
|
+
if (!table) throw new Error(`Missing table name in snapshot for model "${model}"`);
|
|
148
150
|
const refModel = Object.entries(previousSnapshot).find(([, s]) => s.table === references.references);
|
|
149
151
|
const fkType = refModel && refModel[1].idType === 'string' ? 'VARCHAR(255)' : 'INTEGER';
|
|
150
152
|
const constraintName = `fk_${table}_${column}`;
|
|
@@ -198,7 +200,7 @@ export async function generateMigration(description: string = 'migration', confi
|
|
|
198
200
|
const combinedHasChanges = diff.hasChanges || viewDiff.hasChanges;
|
|
199
201
|
|
|
200
202
|
if (!combinedHasChanges) {
|
|
201
|
-
log.db
|
|
203
|
+
log.db?.('No schema changes detected.');
|
|
202
204
|
return null;
|
|
203
205
|
}
|
|
204
206
|
|
|
@@ -216,7 +218,7 @@ export async function generateMigration(description: string = 'migration', confi
|
|
|
216
218
|
await createFile(path.join(migrationsPath, filename), content);
|
|
217
219
|
await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(combinedSnapshot, null, 2));
|
|
218
220
|
|
|
219
|
-
log.db
|
|
221
|
+
log.db?.(`Migration generated: ${filename}`);
|
|
220
222
|
|
|
221
223
|
return { filename, content, snapshot: combinedSnapshot };
|
|
222
224
|
}
|
|
@@ -122,7 +122,7 @@ export default class PostgresDB {
|
|
|
122
122
|
const pending = files.filter(f => !applied.includes(f));
|
|
123
123
|
|
|
124
124
|
if (pending.length > 0) {
|
|
125
|
-
this.deps.log.db
|
|
125
|
+
this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
|
|
126
126
|
|
|
127
127
|
const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
|
|
128
128
|
|
|
@@ -132,13 +132,13 @@ export default class PostgresDB {
|
|
|
132
132
|
const { up } = this.deps.parseMigrationFile(content);
|
|
133
133
|
|
|
134
134
|
await this.deps.applyMigration(this.requirePool(), filename, up, this.pgConfig.migrationsTable as string | undefined);
|
|
135
|
-
this.deps.log.db
|
|
135
|
+
this.deps.log.db?.(`Applied migration: ${filename}`);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
// Reload records after applying migrations
|
|
139
139
|
await this.loadMemoryRecords();
|
|
140
140
|
} else {
|
|
141
|
-
this.deps.log.warn
|
|
141
|
+
this.deps.log.warn?.('Skipping pending migrations. Schema may be outdated.');
|
|
142
142
|
}
|
|
143
143
|
} else if (files.length === 0) {
|
|
144
144
|
const schemas = this.deps.introspectModels();
|
|
@@ -156,11 +156,11 @@ export default class PostgresDB {
|
|
|
156
156
|
if (result) {
|
|
157
157
|
const { up } = this.deps.parseMigrationFile(result.content);
|
|
158
158
|
await this.deps.applyMigration(this.requirePool(), result.filename, up, this.pgConfig.migrationsTable as string | undefined);
|
|
159
|
-
this.deps.log.db
|
|
159
|
+
this.deps.log.db?.(`Applied migration: ${result.filename}`);
|
|
160
160
|
await this.loadMemoryRecords();
|
|
161
161
|
}
|
|
162
162
|
} else {
|
|
163
|
-
this.deps.log.warn
|
|
163
|
+
this.deps.log.warn?.('Skipping initial migration. Tables may not exist.');
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
}
|
|
@@ -173,8 +173,8 @@ export default class PostgresDB {
|
|
|
173
173
|
const drift = this.deps.detectSchemaDrift(schemas, snapshot as Parameters<typeof detectSchemaDrift>[1]);
|
|
174
174
|
|
|
175
175
|
if (drift.hasChanges) {
|
|
176
|
-
this.deps.log.warn
|
|
177
|
-
this.deps.log.warn
|
|
176
|
+
this.deps.log.warn?.('Schema drift detected: models have changed since the last migration.');
|
|
177
|
+
this.deps.log.warn?.('Run `stonyx db:generate-migration` to create a new migration.');
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
}
|
|
@@ -200,7 +200,7 @@ export default class PostgresDB {
|
|
|
200
200
|
for (const modelName of order) {
|
|
201
201
|
const { modelClass } = Orm.instance.getRecordClasses(modelName) as { modelClass: { memory?: boolean } };
|
|
202
202
|
if (modelClass?.memory === false) {
|
|
203
|
-
this.deps.log.db
|
|
203
|
+
this.deps.log.db?.(`Skipping memory load for '${modelName}' (memory: false)`);
|
|
204
204
|
continue;
|
|
205
205
|
}
|
|
206
206
|
|
|
@@ -217,7 +217,7 @@ export default class PostgresDB {
|
|
|
217
217
|
} catch (error) {
|
|
218
218
|
// 42P01 = undefined_table (PG equivalent of ER_NO_SUCH_TABLE)
|
|
219
219
|
if (isDbError(error) && error.code === '42P01') {
|
|
220
|
-
this.deps.log.db
|
|
220
|
+
this.deps.log.db?.(`Table '${schema.table}' does not exist yet. Skipping load for '${modelName}'.`);
|
|
221
221
|
continue;
|
|
222
222
|
}
|
|
223
223
|
|
|
@@ -231,7 +231,7 @@ export default class PostgresDB {
|
|
|
231
231
|
for (const [viewName, viewSchema] of Object.entries(viewSchemas)) {
|
|
232
232
|
const { modelClass: viewClass } = Orm.instance.getRecordClasses(viewName) as { modelClass: { memory?: boolean } };
|
|
233
233
|
if (viewClass?.memory !== true) {
|
|
234
|
-
this.deps.log.db
|
|
234
|
+
this.deps.log.db?.(`Skipping memory load for view '${viewName}' (memory: false)`);
|
|
235
235
|
continue;
|
|
236
236
|
}
|
|
237
237
|
|
|
@@ -256,7 +256,7 @@ export default class PostgresDB {
|
|
|
256
256
|
}
|
|
257
257
|
} catch (error) {
|
|
258
258
|
if (isDbError(error) && error.code === '42P01') {
|
|
259
|
-
this.deps.log.db
|
|
259
|
+
this.deps.log.db?.(`View '${viewSchema.viewName}' does not exist yet. Skipping load for '${viewName}'.`);
|
|
260
260
|
continue;
|
|
261
261
|
}
|
|
262
262
|
throw error;
|
|
@@ -345,13 +345,14 @@ export default class PostgresDB {
|
|
|
345
345
|
|
|
346
346
|
if (!schema) return [];
|
|
347
347
|
|
|
348
|
-
const
|
|
348
|
+
const resolvedSchema = schema;
|
|
349
|
+
const { sql, values } = this.deps.buildSelect(resolvedSchema.table, conditions);
|
|
349
350
|
|
|
350
351
|
try {
|
|
351
352
|
const result = await this.requirePool().query(sql, values);
|
|
352
353
|
|
|
353
354
|
const records = result.rows.map(row => {
|
|
354
|
-
const rawData = this._rowToRawData(row,
|
|
355
|
+
const rawData = this._rowToRawData(row, resolvedSchema);
|
|
355
356
|
return this.deps.createRecord(modelName, rawData, { isDbRecord: true, serialize: false, transform: false }) as unknown as OrmRecord;
|
|
356
357
|
});
|
|
357
358
|
|
|
@@ -93,9 +93,10 @@ export function introspectModels(): Record<string, ModelSchema> {
|
|
|
93
93
|
|
|
94
94
|
// Build foreign keys from belongsTo relationships
|
|
95
95
|
for (const [relName, targetModelName] of Object.entries(relationships.belongsTo)) {
|
|
96
|
+
if (!targetModelName) continue;
|
|
96
97
|
const fkColumn = `${relName}_id`;
|
|
97
98
|
foreignKeys[fkColumn] = {
|
|
98
|
-
references: sanitizeTableName(getPluralName(targetModelName
|
|
99
|
+
references: sanitizeTableName(getPluralName(targetModelName)),
|
|
99
100
|
column: 'id',
|
|
100
101
|
};
|
|
101
102
|
}
|
|
@@ -189,7 +190,7 @@ export function getTopologicalOrder(schemas: Record<string, ModelSchema>): strin
|
|
|
189
190
|
|
|
190
191
|
// Visit dependencies (belongsTo targets) first
|
|
191
192
|
for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
|
|
192
|
-
visit(targetModelName
|
|
193
|
+
if (targetModelName) visit(targetModelName);
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
order.push(name);
|
|
@@ -233,11 +234,13 @@ export function introspectViews(): Record<string, ViewSchema> {
|
|
|
233
234
|
|
|
234
235
|
if (relInfo?.type === 'belongsTo') {
|
|
235
236
|
relationships.belongsTo[key] = relInfo.modelName;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
237
|
+
if (relInfo.modelName) {
|
|
238
|
+
const fkColumn = `${key}_id`;
|
|
239
|
+
foreignKeys[fkColumn] = {
|
|
240
|
+
references: sanitizeTableName(getPluralName(relInfo.modelName)),
|
|
241
|
+
column: 'id',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
241
244
|
} else if (relInfo?.type === 'hasMany') {
|
|
242
245
|
relationships.hasMany[key] = relInfo.modelName;
|
|
243
246
|
} else if (property instanceof ModelProperty) {
|
package/src/relationships.ts
CHANGED
|
@@ -13,11 +13,12 @@ export function getRelationships(type: string, sourceModel: string, targetModel:
|
|
|
13
13
|
// create relationship map for this type of it doesn't already exist
|
|
14
14
|
if (!allRelationships.has(sourceModel)) allRelationships.set(sourceModel, new Map());
|
|
15
15
|
|
|
16
|
-
const modelRelationship = allRelationships.get(sourceModel)
|
|
16
|
+
const modelRelationship = allRelationships.get(sourceModel) as Map<string, Map<unknown, unknown>> | undefined;
|
|
17
|
+
if (!modelRelationship) return undefined;
|
|
17
18
|
|
|
18
19
|
if (!modelRelationship.has(targetModel)) modelRelationship.set(targetModel, new Map());
|
|
19
20
|
|
|
20
|
-
const relationship = modelRelationship.get(targetModel)
|
|
21
|
+
const relationship = modelRelationship.get(targetModel) as Map<unknown, unknown> | undefined;
|
|
21
22
|
|
|
22
23
|
// TODO: Determine whether already having id should be handled differently
|
|
23
24
|
//if (relationship.has(relationshipId)) return;
|
package/src/setup-rest-server.ts
CHANGED
|
@@ -14,7 +14,7 @@ interface AccessInstance {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export default async function(route: string, accessPath: string, metaRoute: boolean): Promise<void> {
|
|
17
|
-
|
|
17
|
+
const accessFiles: Record<string, (request: unknown) => unknown> = {};
|
|
18
18
|
|
|
19
19
|
try {
|
|
20
20
|
await forEachFileImport(accessPath, (accessClass: unknown) => {
|
|
@@ -31,14 +31,14 @@ export default async function(route: string, accessPath: string, metaRoute: bool
|
|
|
31
31
|
for (const model of models === '*' ? availableModels : models) {
|
|
32
32
|
if (model === dbKey) continue;
|
|
33
33
|
if (!store.data.has(model)) throw new Error(`Unable to define access for Invalid Model "${model}". Model does not exist`);
|
|
34
|
-
if (accessFiles
|
|
34
|
+
if (accessFiles[model]) throw new Error(`Access for model "${model}" has already been defined by another access class.`);
|
|
35
35
|
|
|
36
|
-
accessFiles
|
|
36
|
+
accessFiles[model] = accessInstance.access;
|
|
37
37
|
}
|
|
38
38
|
});
|
|
39
39
|
} catch (error) {
|
|
40
|
-
log.error
|
|
41
|
-
log.warn
|
|
40
|
+
log.error?.(error instanceof Error ? error.message : String(error));
|
|
41
|
+
log.warn?.('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
await waitForModule('rest-server');
|
|
@@ -47,7 +47,7 @@ export default async function(route: string, accessPath: string, metaRoute: bool
|
|
|
47
47
|
const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
|
|
48
48
|
|
|
49
49
|
// Configure endpoints for models and views with access configuration
|
|
50
|
-
for (const [model, access] of Object.entries(accessFiles
|
|
50
|
+
for (const [model, access] of Object.entries(accessFiles)) {
|
|
51
51
|
const pluralizedModel = getPluralName(model);
|
|
52
52
|
const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
|
|
53
53
|
RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
|
|
@@ -55,11 +55,8 @@ export default async function(route: string, accessPath: string, metaRoute: bool
|
|
|
55
55
|
|
|
56
56
|
// Mount the meta route when metaRoute config is enabled
|
|
57
57
|
if (metaRoute) {
|
|
58
|
-
log.warn
|
|
58
|
+
log.warn?.('SECURITY RISK! - Meta route is enabled via metaRoute config. This feature is intended for development purposes only!');
|
|
59
59
|
|
|
60
60
|
RestServer.instance.mountRoute(MetaRequest, { name });
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
// Cleanup references
|
|
64
|
-
accessFiles = null;
|
|
65
62
|
}
|