@stonyx/orm 0.2.1-beta.86 → 0.2.1-beta.88

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.
Files changed (47) hide show
  1. package/dist/aggregates.js +9 -6
  2. package/dist/db.js +4 -4
  3. package/dist/hooks.js +6 -2
  4. package/dist/main.js +3 -1
  5. package/dist/manage-record.js +6 -2
  6. package/dist/mysql/migration-generator.js +15 -6
  7. package/dist/mysql/migration-runner.js +5 -0
  8. package/dist/mysql/mysql-db.js +24 -14
  9. package/dist/mysql/schema-introspector.js +11 -6
  10. package/dist/orm-request.js +19 -11
  11. package/dist/postgres/connection.js +3 -1
  12. package/dist/postgres/migration-generator.js +9 -5
  13. package/dist/postgres/migration-runner.js +5 -0
  14. package/dist/postgres/postgres-db.js +14 -13
  15. package/dist/postgres/schema-introspector.js +11 -6
  16. package/dist/postgres/type-map.js +3 -0
  17. package/dist/relationships.js +2 -0
  18. package/dist/setup-rest-server.js +4 -6
  19. package/dist/store.js +2 -0
  20. package/dist/timescale/query-builder.d.ts +2 -0
  21. package/dist/timescale/query-builder.js +30 -2
  22. package/dist/utils.js +2 -1
  23. package/dist/view-resolver.js +3 -1
  24. package/package.json +1 -1
  25. package/src/aggregates.ts +9 -7
  26. package/src/belongs-to.ts +1 -1
  27. package/src/db.ts +4 -4
  28. package/src/hooks.ts +4 -2
  29. package/src/main.ts +3 -2
  30. package/src/manage-record.ts +5 -2
  31. package/src/mysql/migration-generator.ts +12 -7
  32. package/src/mysql/migration-runner.ts +5 -0
  33. package/src/mysql/mysql-db.ts +23 -17
  34. package/src/mysql/schema-introspector.ts +10 -7
  35. package/src/orm-request.ts +19 -12
  36. package/src/postgres/connection.ts +3 -1
  37. package/src/postgres/migration-generator.ts +7 -5
  38. package/src/postgres/migration-runner.ts +5 -0
  39. package/src/postgres/postgres-db.ts +14 -13
  40. package/src/postgres/schema-introspector.ts +10 -7
  41. package/src/postgres/type-map.ts +4 -0
  42. package/src/relationships.ts +3 -2
  43. package/src/setup-rest-server.ts +7 -10
  44. package/src/store.ts +2 -1
  45. package/src/timescale/query-builder.ts +39 -2
  46. package/src/utils.ts +2 -1
  47. package/src/view-resolver.ts +2 -1
@@ -18,19 +18,22 @@ export class AggregateProperty {
18
18
  return null;
19
19
  return 0;
20
20
  }
21
+ if (this.aggregateType === 'count')
22
+ return relatedRecords.length;
23
+ const field = this.field;
24
+ if (!field)
25
+ return null;
21
26
  switch (this.aggregateType) {
22
- case 'count':
23
- return relatedRecords.length;
24
27
  case 'sum':
25
28
  return relatedRecords.reduce((acc, record) => {
26
- const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
29
+ const val = parseFloat(record?.__data?.[field] ?? record?.[field]);
27
30
  return acc + (isNaN(val) ? 0 : val);
28
31
  }, 0);
29
32
  case 'avg': {
30
33
  let sum = 0;
31
34
  let count = 0;
32
35
  for (const record of relatedRecords) {
33
- const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
36
+ const val = parseFloat(record?.__data?.[field] ?? record?.[field]);
34
37
  if (!isNaN(val)) {
35
38
  sum += val;
36
39
  count++;
@@ -41,7 +44,7 @@ export class AggregateProperty {
41
44
  case 'min': {
42
45
  let min = null;
43
46
  for (const record of relatedRecords) {
44
- const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
47
+ const val = parseFloat(record?.__data?.[field] ?? record?.[field]);
45
48
  if (!isNaN(val) && (min === null || val < min))
46
49
  min = val;
47
50
  }
@@ -50,7 +53,7 @@ export class AggregateProperty {
50
53
  case 'max': {
51
54
  let max = null;
52
55
  for (const record of relatedRecords) {
53
- const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
56
+ const val = parseFloat(record?.__data?.[field] ?? record?.[field]);
54
57
  if (!isNaN(val) && (max === null || val > max))
55
58
  max = val;
56
59
  }
package/dist/db.js CHANGED
@@ -66,7 +66,7 @@ export default class DB {
66
66
  const data = await readFile(dbFilePath, { json: true });
67
67
  const hasData = collectionKeys.some(key => Array.isArray(data[key]) && data[key].length > 0);
68
68
  if (hasData) {
69
- 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`);
69
+ 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`);
70
70
  process.exit(1);
71
71
  }
72
72
  }
@@ -76,7 +76,7 @@ export default class DB {
76
76
  if (dirExists) {
77
77
  const hasCollectionFiles = (await Promise.all(collectionKeys.map(key => fileExists(path.join(dirPath, `${key}.json`))))).some(Boolean);
78
78
  if (hasCollectionFiles) {
79
- 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`);
79
+ 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`);
80
80
  process.exit(1);
81
81
  }
82
82
  }
@@ -138,11 +138,11 @@ export default class DB {
138
138
  await updateFile(dbFilePath, skeleton, { json: true });
139
139
  else
140
140
  await createFile(dbFilePath, skeleton, { json: true });
141
- log.db(`DB has been successfully saved to ${config.orm.db.directory}/ directory`);
141
+ log.db?.(`DB has been successfully saved to ${config.orm.db.directory}/ directory`);
142
142
  return;
143
143
  }
144
144
  await updateFile(`${config.rootPath}/${file}`, jsonData, { json: true });
145
- log.db(`DB has been successfully saved to ${file}`);
145
+ log.db?.(`DB has been successfully saved to ${file}`);
146
146
  }
147
147
  async getRecord() {
148
148
  const { mode } = config.orm.db;
package/dist/hooks.js CHANGED
@@ -31,7 +31,9 @@ export function beforeHook(operation, model, handler) {
31
31
  if (!beforeHooks.has(key)) {
32
32
  beforeHooks.set(key, []);
33
33
  }
34
- beforeHooks.get(key).push(handler);
34
+ const hooks = beforeHooks.get(key);
35
+ if (hooks)
36
+ hooks.push(handler);
35
37
  // Return unsubscribe function
36
38
  return () => {
37
39
  const hooks = beforeHooks.get(key);
@@ -56,7 +58,9 @@ export function afterHook(operation, model, handler) {
56
58
  if (!afterHooks.has(key)) {
57
59
  afterHooks.set(key, []);
58
60
  }
59
- afterHooks.get(key).push(handler);
61
+ const hooks = afterHooks.get(key);
62
+ if (hooks)
63
+ hooks.push(handler);
60
64
  // Return unsubscribe function
61
65
  return () => {
62
66
  const hooks = afterHooks.get(key);
package/dist/main.js CHANGED
@@ -145,6 +145,8 @@ export default class Orm {
145
145
  static get db() {
146
146
  if (!Orm.initialized)
147
147
  throw new Error('ORM has not been initialized yet');
148
+ if (!Orm.instance.db)
149
+ throw new Error('ORM database has not been initialized');
148
150
  return Orm.instance.db;
149
151
  }
150
152
  getRecordClasses(modelName) {
@@ -170,7 +172,7 @@ export default class Orm {
170
172
  warn(message) {
171
173
  this.warnings.add(message);
172
174
  setTimeout(() => {
173
- this.warnings.forEach(warning => log.warn(warning));
175
+ this.warnings.forEach(warning => log.warn?.(warning));
174
176
  this.warnings.clear();
175
177
  }, 0);
176
178
  }
@@ -105,8 +105,12 @@ function assignRecordId(modelName, rawData) {
105
105
  rawData.__pendingSqlId = true;
106
106
  return;
107
107
  }
108
- const modelStore = Array.from(store.get(modelName).values());
109
- rawData.id = modelStore.length ? modelStore.at(-1).id + 1 : 1;
108
+ const storeMap = store.get(modelName);
109
+ if (!storeMap)
110
+ throw new Error(`Cannot assign record ID: model "${modelName}" not found in store`);
111
+ const modelStore = Array.from(storeMap.values());
112
+ const lastRecord = modelStore.at(-1);
113
+ rawData.id = lastRecord ? lastRecord.id + 1 : 1;
110
114
  }
111
115
  function isStringIdModel(modelName) {
112
116
  const modelClass = Orm.instance.getRecordClasses(modelName).modelClass;
@@ -4,7 +4,12 @@ import path from 'path';
4
4
  import config from 'stonyx/config';
5
5
  import log from 'stonyx/log';
6
6
  export async function generateMigration(description = 'migration') {
7
- const { migrationsDir } = config.orm.mysql;
7
+ const mysqlConfig = config.orm.mysql;
8
+ if (!mysqlConfig)
9
+ throw new Error('MySQL configuration (config.orm.mysql) is required for migration generation');
10
+ const { migrationsDir } = mysqlConfig;
11
+ if (!migrationsDir)
12
+ throw new Error('MySQL migrationsDir is required in config');
8
13
  const rootPath = config.rootPath;
9
14
  const migrationsPath = path.resolve(rootPath, migrationsDir);
10
15
  await createDirectory(migrationsPath);
@@ -20,7 +25,7 @@ export async function generateMigration(description = 'migration') {
20
25
  const previousViewSnapshotPrelim = extractViewsFromSnapshot(previousSnapshot);
21
26
  const viewDiffPrelim = diffViewSnapshots(previousViewSnapshotPrelim, currentViewSnapshotPrelim);
22
27
  if (!viewDiffPrelim.hasChanges) {
23
- log.db('No schema changes detected.');
28
+ log.db?.('No schema changes detected.');
24
29
  return null;
25
30
  }
26
31
  }
@@ -47,7 +52,9 @@ export async function generateMigration(description = 'migration') {
47
52
  }
48
53
  // Removed columns
49
54
  for (const { model, column, type } of diff.removedColumns) {
50
- const table = previousSnapshot[model].table;
55
+ const table = previousSnapshot[model]?.table;
56
+ if (!table)
57
+ throw new Error(`Missing table name in snapshot for model "${model}"`);
51
58
  upStatements.push(`ALTER TABLE \`${table}\` DROP COLUMN \`${column}\`;`);
52
59
  downStatements.push(`ALTER TABLE \`${table}\` ADD COLUMN \`${column}\` ${type};`);
53
60
  }
@@ -70,7 +77,9 @@ export async function generateMigration(description = 'migration') {
70
77
  }
71
78
  // Removed foreign keys
72
79
  for (const { model, column, references } of diff.removedForeignKeys) {
73
- const table = previousSnapshot[model].table;
80
+ const table = previousSnapshot[model]?.table;
81
+ if (!table)
82
+ throw new Error(`Missing table name in snapshot for model "${model}"`);
74
83
  // Resolve FK column type from the referenced table's PK type in previous snapshot
75
84
  const refModel = Object.entries(previousSnapshot).find(([, s]) => s.table === references.references);
76
85
  const fkType = refModel && refModel[1].idType === 'string' ? 'VARCHAR(255)' : 'INT';
@@ -119,7 +128,7 @@ export async function generateMigration(description = 'migration') {
119
128
  }
120
129
  const combinedHasChanges = diff.hasChanges || viewDiff.hasChanges;
121
130
  if (!combinedHasChanges) {
122
- log.db('No schema changes detected.');
131
+ log.db?.('No schema changes detected.');
123
132
  return null;
124
133
  }
125
134
  // Merge view snapshot into the main snapshot
@@ -133,7 +142,7 @@ export async function generateMigration(description = 'migration') {
133
142
  const content = `-- UP\n${upStatements.join('\n')}\n\n-- DOWN\n${downStatements.join('\n')}\n`;
134
143
  await createFile(path.join(migrationsPath, filename), content);
135
144
  await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(combinedSnapshot, null, 2));
136
- log.db(`Migration generated: ${filename}`);
145
+ log.db?.(`Migration generated: ${filename}`);
137
146
  return { filename, content, snapshot: combinedSnapshot };
138
147
  }
139
148
  export async function loadLatestSnapshot(migrationsPath) {
@@ -1,6 +1,8 @@
1
1
  import { fileExists } from '@stonyx/utils/file';
2
2
  import fs from 'fs/promises';
3
+ import { validateIdentifier } from './query-builder.js';
3
4
  export async function ensureMigrationsTable(pool, tableName = '__migrations') {
5
+ validateIdentifier(tableName, 'migration table name');
4
6
  await pool.execute(`
5
7
  CREATE TABLE IF NOT EXISTS \`${tableName}\` (
6
8
  id INT AUTO_INCREMENT PRIMARY KEY,
@@ -10,6 +12,7 @@ export async function ensureMigrationsTable(pool, tableName = '__migrations') {
10
12
  `);
11
13
  }
12
14
  export async function getAppliedMigrations(pool, tableName = '__migrations') {
15
+ validateIdentifier(tableName, 'migration table name');
13
16
  const [rows] = await pool.execute(`SELECT filename FROM \`${tableName}\` ORDER BY id ASC`);
14
17
  return rows.map(row => row.filename);
15
18
  }
@@ -37,6 +40,7 @@ export function parseMigrationFile(content) {
37
40
  return { up, down };
38
41
  }
39
42
  export async function applyMigration(pool, filename, upSql, tableName = '__migrations') {
43
+ validateIdentifier(tableName, 'migration table name');
40
44
  const connection = await pool.getConnection();
41
45
  try {
42
46
  await connection.beginTransaction();
@@ -57,6 +61,7 @@ export async function applyMigration(pool, filename, upSql, tableName = '__migra
57
61
  }
58
62
  }
59
63
  export async function rollbackMigration(pool, filename, downSql, tableName = '__migrations') {
64
+ validateIdentifier(tableName, 'migration table name');
60
65
  const connection = await pool.getConnection();
61
66
  try {
62
67
  await connection.beginTransaction();
@@ -32,7 +32,10 @@ export default class MysqlDB {
32
32
  MysqlDB.instance = this;
33
33
  this.deps = { ...defaultDeps, ...deps };
34
34
  this.pool = null;
35
- this.mysqlConfig = this.deps.config.orm.mysql;
35
+ const mysqlConfig = this.deps.config.orm.mysql;
36
+ if (!mysqlConfig)
37
+ throw new Error('MySQL configuration (config.orm.mysql) is required');
38
+ this.mysqlConfig = mysqlConfig;
36
39
  }
37
40
  requirePool() {
38
41
  if (!this.pool)
@@ -45,26 +48,28 @@ export default class MysqlDB {
45
48
  await this.loadMemoryRecords();
46
49
  }
47
50
  async startup() {
51
+ if (!this.mysqlConfig.migrationsDir)
52
+ throw new Error('MySQL migrationsDir is required in config');
48
53
  const migrationsPath = this.deps.path.resolve(this.deps.config.rootPath, this.mysqlConfig.migrationsDir);
49
54
  // Check for pending migrations
50
55
  const applied = await this.deps.getAppliedMigrations(this.requirePool(), this.mysqlConfig.migrationsTable);
51
56
  const files = await this.deps.getMigrationFiles(migrationsPath);
52
57
  const pending = files.filter(f => !applied.includes(f));
53
58
  if (pending.length > 0) {
54
- this.deps.log.db(`${pending.length} pending migration(s) found.`);
59
+ this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
55
60
  const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
56
61
  if (shouldApply) {
57
62
  for (const filename of pending) {
58
63
  const content = await this.deps.readFile(this.deps.path.join(migrationsPath, filename));
59
64
  const { up } = this.deps.parseMigrationFile(content);
60
65
  await this.deps.applyMigration(this.requirePool(), filename, up, this.mysqlConfig.migrationsTable);
61
- this.deps.log.db(`Applied migration: ${filename}`);
66
+ this.deps.log.db?.(`Applied migration: ${filename}`);
62
67
  }
63
68
  // Reload records after applying migrations
64
69
  await this.loadMemoryRecords();
65
70
  }
66
71
  else {
67
- this.deps.log.warn('Skipping pending migrations. Schema may be outdated.');
72
+ this.deps.log.warn?.('Skipping pending migrations. Schema may be outdated.');
68
73
  }
69
74
  }
70
75
  else if (files.length === 0) {
@@ -78,23 +83,25 @@ export default class MysqlDB {
78
83
  if (result) {
79
84
  const { up } = this.deps.parseMigrationFile(result.content);
80
85
  await this.deps.applyMigration(this.requirePool(), result.filename, up, this.mysqlConfig.migrationsTable);
81
- this.deps.log.db(`Applied migration: ${result.filename}`);
86
+ this.deps.log.db?.(`Applied migration: ${result.filename}`);
82
87
  await this.loadMemoryRecords();
83
88
  }
84
89
  }
85
90
  else {
86
- this.deps.log.warn('Skipping initial migration. Tables may not exist.');
91
+ this.deps.log.warn?.('Skipping initial migration. Tables may not exist.');
87
92
  }
88
93
  }
89
94
  }
90
95
  // Check for schema drift
91
96
  const schemas = this.deps.introspectModels();
97
+ if (!this.mysqlConfig.migrationsDir)
98
+ throw new Error('MySQL migrationsDir is required in config');
92
99
  const snapshot = await this.deps.loadLatestSnapshot(this.deps.path.resolve(this.deps.config.rootPath, this.mysqlConfig.migrationsDir));
93
100
  if (Object.keys(snapshot).length > 0) {
94
101
  const drift = this.deps.detectSchemaDrift(schemas, snapshot);
95
102
  if (drift.hasChanges) {
96
- this.deps.log.warn('Schema drift detected: models have changed since the last migration.');
97
- this.deps.log.warn('Run `stonyx db:generate-migration` to create a new migration.');
103
+ this.deps.log.warn?.('Schema drift detected: models have changed since the last migration.');
104
+ this.deps.log.warn?.('Run `stonyx db:generate-migration` to create a new migration.');
98
105
  }
99
106
  }
100
107
  }
@@ -117,7 +124,7 @@ export default class MysqlDB {
117
124
  // Check the model's memory flag — skip non-memory models
118
125
  const { modelClass } = Orm.instance.getRecordClasses(modelName);
119
126
  if (modelClass?.memory === false) {
120
- this.deps.log.db(`Skipping memory load for '${modelName}' (memory: false)`);
127
+ this.deps.log.db?.(`Skipping memory load for '${modelName}' (memory: false)`);
121
128
  continue;
122
129
  }
123
130
  const schema = schemas[modelName];
@@ -133,7 +140,7 @@ export default class MysqlDB {
133
140
  catch (error) {
134
141
  // Table may not exist yet (pre-migration) — skip gracefully
135
142
  if (isDbError(error) && error.code === 'ER_NO_SUCH_TABLE') {
136
- this.deps.log.db(`Table '${schema.table}' does not exist yet. Skipping load for '${modelName}'.`);
143
+ this.deps.log.db?.(`Table '${schema.table}' does not exist yet. Skipping load for '${modelName}'.`);
137
144
  continue;
138
145
  }
139
146
  throw error;
@@ -144,7 +151,7 @@ export default class MysqlDB {
144
151
  for (const [viewName, viewSchema] of Object.entries(viewSchemas)) {
145
152
  const { modelClass: viewClass } = Orm.instance.getRecordClasses(viewName);
146
153
  if (viewClass?.memory !== true) {
147
- this.deps.log.db(`Skipping memory load for view '${viewName}' (memory: false)`);
154
+ this.deps.log.db?.(`Skipping memory load for view '${viewName}' (memory: false)`);
148
155
  continue;
149
156
  }
150
157
  const schema = { table: viewSchema.viewName, columns: viewSchema.columns || {}, foreignKeys: viewSchema.foreignKeys || {} };
@@ -159,7 +166,7 @@ export default class MysqlDB {
159
166
  }
160
167
  catch (error) {
161
168
  if (isDbError(error) && error.code === 'ER_NO_SUCH_TABLE') {
162
- this.deps.log.db(`View '${viewSchema.viewName}' does not exist yet. Skipping load for '${viewName}'.`);
169
+ this.deps.log.db?.(`View '${viewSchema.viewName}' does not exist yet. Skipping load for '${viewName}'.`);
163
170
  continue;
164
171
  }
165
172
  throw error;
@@ -224,12 +231,13 @@ export default class MysqlDB {
224
231
  }
225
232
  if (!schema)
226
233
  return [];
227
- const { sql, values } = this.deps.buildSelect(schema.table, conditions);
234
+ const resolvedSchema = schema;
235
+ const { sql, values } = this.deps.buildSelect(resolvedSchema.table, conditions);
228
236
  try {
229
237
  const result = await this.requirePool().execute(sql, values);
230
238
  const rows = result[0];
231
239
  const records = rows.map(row => {
232
- const rawData = this._rowToRawData(row, schema);
240
+ const rawData = this._rowToRawData(row, resolvedSchema);
233
241
  return this.deps.createRecord(modelName, rawData, { isDbRecord: true, serialize: false, transform: false });
234
242
  });
235
243
  // Don't let memory:false records accumulate in the store
@@ -328,6 +336,8 @@ export default class MysqlDB {
328
336
  const pendingId = record.id;
329
337
  const realId = result.insertId;
330
338
  const modelStore = this.deps.store.get(modelName);
339
+ if (!modelStore)
340
+ throw new Error(`Model "${modelName}" not found in store during ID re-key`);
331
341
  modelStore.delete(pendingId);
332
342
  record.__data.id = realId;
333
343
  record.id = realId;
@@ -53,6 +53,8 @@ export function introspectModels() {
53
53
  }
54
54
  // Build foreign keys from belongsTo relationships
55
55
  for (const [relName, targetModelName] of Object.entries(relationships.belongsTo)) {
56
+ if (!targetModelName)
57
+ continue;
56
58
  const fkColumn = `${relName}_id`;
57
59
  foreignKeys[fkColumn] = {
58
60
  references: sanitizeTableName(getPluralName(targetModelName)),
@@ -122,7 +124,8 @@ export function getTopologicalOrder(schemas) {
122
124
  return;
123
125
  // Visit dependencies (belongsTo targets) first
124
126
  for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
125
- visit(targetModelName);
127
+ if (targetModelName)
128
+ visit(targetModelName);
126
129
  }
127
130
  order.push(name);
128
131
  }
@@ -158,11 +161,13 @@ export function introspectViews() {
158
161
  const relInfo = getRelationshipInfo(property);
159
162
  if (relInfo?.type === 'belongsTo') {
160
163
  relationships.belongsTo[key] = relInfo.modelName;
161
- const fkColumn = `${key}_id`;
162
- foreignKeys[fkColumn] = {
163
- references: sanitizeTableName(getPluralName(relInfo.modelName)),
164
- column: 'id',
165
- };
164
+ if (relInfo.modelName) {
165
+ const fkColumn = `${key}_id`;
166
+ foreignKeys[fkColumn] = {
167
+ references: sanitizeTableName(getPluralName(relInfo.modelName)),
168
+ column: 'id',
169
+ };
170
+ }
166
171
  }
167
172
  else if (relInfo?.type === 'hasMany') {
168
173
  relationships.hasMany[key] = relInfo.modelName;
@@ -69,7 +69,7 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
69
69
  return response;
70
70
  const includedRecords = collectIncludedRecords(recordOrRecords, includes);
71
71
  if (includedRecords.length > 0) {
72
- response.included = includedRecords.map(record => record.toJSON({ baseUrl }));
72
+ response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
73
73
  }
74
74
  return response;
75
75
  }
@@ -96,6 +96,8 @@ function traverseIncludePath(currentRecords, includePath, depth, seen, included)
96
96
  for (const relatedRecord of recordsToProcess) {
97
97
  if (!relatedRecord)
98
98
  continue;
99
+ if (!relatedRecord.__model)
100
+ continue;
99
101
  const type = relatedRecord.__model.__name;
100
102
  const id = relatedRecord.id;
101
103
  // Initialize Set for this type if needed
@@ -207,7 +209,7 @@ export default class OrmRequest extends Request {
207
209
  if (queryFilterPredicate)
208
210
  recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
209
211
  const baseUrl = getBaseUrl(request);
210
- const data = recordsToReturn.map(record => record.toJSON({ fields: modelFields, baseUrl }));
212
+ const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
211
213
  return buildResponse(data, request.query?.include, recordsToReturn, {
212
214
  links: { self: `${baseUrl}/${pluralizedModel}` },
213
215
  baseUrl
@@ -220,7 +222,7 @@ export default class OrmRequest extends Request {
220
222
  const fieldsMap = parseFields(request.query);
221
223
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
222
224
  const baseUrl = getBaseUrl(request);
223
- return buildResponse(record.toJSON({ fields: modelFields, baseUrl }), request.query?.include, record, {
225
+ return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
224
226
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
225
227
  baseUrl
226
228
  });
@@ -246,7 +248,7 @@ export default class OrmRequest extends Request {
246
248
  }
247
249
  const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
248
250
  const record = createRecord(model, recordAttributes, { serialize: false });
249
- return { data: record.toJSON({ fields: modelFields }) };
251
+ return { data: record.toJSON?.({ fields: modelFields }) };
250
252
  };
251
253
  const updateHandler = async ({ body, params }) => {
252
254
  const record = await store.find(model, getId(params));
@@ -277,7 +279,7 @@ export default class OrmRequest extends Request {
277
279
  updateRecord(record, relUpdates);
278
280
  }
279
281
  }
280
- return { data: record.toJSON() };
282
+ return { data: record.toJSON?.() };
281
283
  };
282
284
  const deleteHandler = ({ params }) => {
283
285
  store.remove(model, getId(params));
@@ -340,8 +342,9 @@ export default class OrmRequest extends Request {
340
342
  // Execute main handler
341
343
  const response = await handler(request, state);
342
344
  // Persist to SQL database for write operations
343
- if (Orm.instance.sqlDb && WRITE_OPERATIONS.has(operation)) {
344
- await Orm.instance.sqlDb.persist(operation, this.model, context, response);
345
+ const sqlDb = Orm.instance.sqlDb;
346
+ if (sqlDb && WRITE_OPERATIONS.has(operation)) {
347
+ await sqlDb.persist(operation, this.model, context, response);
345
348
  }
346
349
  // Add response and relevant records to context
347
350
  context.response = response;
@@ -390,11 +393,11 @@ export default class OrmRequest extends Request {
390
393
  let data;
391
394
  if (info.isArray) {
392
395
  // hasMany - return array
393
- data = (relatedData || []).map(r => r.toJSON({ baseUrl }));
396
+ data = (relatedData || []).map(r => r.toJSON?.({ baseUrl }));
394
397
  }
395
398
  else {
396
399
  // belongsTo - return single or null
397
- data = relatedData ? relatedData.toJSON({ baseUrl }) : null;
400
+ data = relatedData ? relatedData.toJSON?.({ baseUrl }) : null;
398
401
  }
399
402
  return {
400
403
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
@@ -411,11 +414,16 @@ export default class OrmRequest extends Request {
411
414
  let data;
412
415
  if (info.isArray) {
413
416
  // hasMany - return array of linkage objects
414
- data = (relatedData || []).map(r => ({ type: r.__model.__name, id: r.id }));
417
+ data = (relatedData || [])
418
+ .filter((r) => Boolean(r.__model))
419
+ .map(r => ({ type: r.__model.__name, id: r.id }));
415
420
  }
416
421
  else {
417
422
  // belongsTo - return single linkage or null
418
- data = relatedData ? { type: relatedData.__model.__name, id: relatedData.id } : null;
423
+ const model = relatedData ? relatedData.__model : undefined;
424
+ data = model
425
+ ? { type: model.__name, id: relatedData.id }
426
+ : null;
419
427
  }
420
428
  return {
421
429
  links: {
@@ -1,3 +1,4 @@
1
+ import { validateIdentifier } from './query-builder.js';
1
2
  let pool = null;
2
3
  /**
3
4
  * Create or return the singleton pg Pool.
@@ -18,7 +19,8 @@ export async function getPool(pgConfig, extensions = ['vector']) {
18
19
  });
19
20
  // Enable requested PostgreSQL extensions
20
21
  for (const ext of extensions) {
21
- await pool.query(`CREATE EXTENSION IF NOT EXISTS ${ext}`);
22
+ validateIdentifier(ext, 'extension name');
23
+ await pool.query(`CREATE EXTENSION IF NOT EXISTS "${ext}"`);
22
24
  }
23
25
  return pool;
24
26
  }
@@ -19,7 +19,7 @@ export async function generateMigration(description = 'migration', configKey = '
19
19
  const previousViewSnapshotPrelim = extractViewsFromSnapshot(previousSnapshot);
20
20
  const viewDiffPrelim = diffViewSnapshots(previousViewSnapshotPrelim, currentViewSnapshotPrelim);
21
21
  if (!viewDiffPrelim.hasChanges) {
22
- log.db('No schema changes detected.');
22
+ log.db?.('No schema changes detected.');
23
23
  return null;
24
24
  }
25
25
  }
@@ -59,7 +59,9 @@ export async function generateMigration(description = 'migration', configKey = '
59
59
  }
60
60
  // Removed columns
61
61
  for (const { model, column, type } of diff.removedColumns) {
62
- const table = previousSnapshot[model].table;
62
+ const table = previousSnapshot[model]?.table;
63
+ if (!table)
64
+ throw new Error(`Missing table name in snapshot for model "${model}"`);
63
65
  upStatements.push(`ALTER TABLE "${table}" DROP COLUMN "${column}";`);
64
66
  downStatements.push(`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type};`);
65
67
  }
@@ -82,7 +84,9 @@ export async function generateMigration(description = 'migration', configKey = '
82
84
  }
83
85
  // Removed foreign keys
84
86
  for (const { model, column, references } of diff.removedForeignKeys) {
85
- const table = previousSnapshot[model].table;
87
+ const table = previousSnapshot[model]?.table;
88
+ if (!table)
89
+ throw new Error(`Missing table name in snapshot for model "${model}"`);
86
90
  const refModel = Object.entries(previousSnapshot).find(([, s]) => s.table === references.references);
87
91
  const fkType = refModel && refModel[1].idType === 'string' ? 'VARCHAR(255)' : 'INTEGER';
88
92
  const constraintName = `fk_${table}_${column}`;
@@ -131,7 +135,7 @@ export async function generateMigration(description = 'migration', configKey = '
131
135
  }
132
136
  const combinedHasChanges = diff.hasChanges || viewDiff.hasChanges;
133
137
  if (!combinedHasChanges) {
134
- log.db('No schema changes detected.');
138
+ log.db?.('No schema changes detected.');
135
139
  return null;
136
140
  }
137
141
  // Merge view snapshot into the main snapshot
@@ -145,7 +149,7 @@ export async function generateMigration(description = 'migration', configKey = '
145
149
  const content = `-- UP\n${upStatements.join('\n')}\n\n-- DOWN\n${downStatements.join('\n')}\n`;
146
150
  await createFile(path.join(migrationsPath, filename), content);
147
151
  await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(combinedSnapshot, null, 2));
148
- log.db(`Migration generated: ${filename}`);
152
+ log.db?.(`Migration generated: ${filename}`);
149
153
  return { filename, content, snapshot: combinedSnapshot };
150
154
  }
151
155
  export async function loadLatestSnapshot(migrationsPath) {
@@ -1,6 +1,8 @@
1
1
  import { fileExists } from '@stonyx/utils/file';
2
2
  import fs from 'fs/promises';
3
+ import { validateIdentifier } from './query-builder.js';
3
4
  export async function ensureMigrationsTable(pool, tableName = '__migrations') {
5
+ validateIdentifier(tableName, 'migration table name');
4
6
  await pool.query(`
5
7
  CREATE TABLE IF NOT EXISTS "${tableName}" (
6
8
  id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
@@ -10,6 +12,7 @@ export async function ensureMigrationsTable(pool, tableName = '__migrations') {
10
12
  `);
11
13
  }
12
14
  export async function getAppliedMigrations(pool, tableName = '__migrations') {
15
+ validateIdentifier(tableName, 'migration table name');
13
16
  const result = await pool.query(`SELECT filename FROM "${tableName}" ORDER BY id ASC`);
14
17
  return result.rows.map(row => row.filename);
15
18
  }
@@ -37,6 +40,7 @@ export function parseMigrationFile(content) {
37
40
  return { up, down };
38
41
  }
39
42
  export async function applyMigration(pool, filename, upSql, tableName = '__migrations') {
43
+ validateIdentifier(tableName, 'migration table name');
40
44
  const client = await pool.connect();
41
45
  try {
42
46
  await client.query('BEGIN');
@@ -56,6 +60,7 @@ export async function applyMigration(pool, filename, upSql, tableName = '__migra
56
60
  }
57
61
  }
58
62
  export async function rollbackMigration(pool, filename, downSql, tableName = '__migrations') {
63
+ validateIdentifier(tableName, 'migration table name');
59
64
  const client = await pool.connect();
60
65
  try {
61
66
  await client.query('BEGIN');