@stonyx/orm 0.3.2-beta.6 → 0.3.2-beta.8

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.
@@ -1,91 +1,12 @@
1
- const {
2
- ORM_ACCESS_PATH,
3
- ORM_MODEL_PATH,
4
- ORM_REST_ROUTE,
5
- ORM_SERIALIZER_PATH,
6
- ORM_TRANSFORM_PATH,
7
- ORM_VIEW_PATH,
8
- ORM_USE_REST_SERVER,
9
- DB_AUTO_SAVE,
10
- DB_FILE,
11
- DB_MODE,
12
- DB_DIRECTORY,
13
- DB_SCHEMA_PATH,
14
- DB_SAVE_INTERVAL,
15
- MYSQL_HOST,
16
- MYSQL_PORT,
17
- MYSQL_USER,
18
- MYSQL_PASSWORD,
19
- MYSQL_DATABASE,
20
- MYSQL_CONNECTION_LIMIT,
21
- MYSQL_MIGRATIONS_DIR,
22
- PG_HOST,
23
- PG_PORT,
24
- PG_USER,
25
- PG_PASSWORD,
26
- PG_DATABASE,
27
- PG_CONNECTION_LIMIT,
28
- PG_MIGRATIONS_DIR,
29
- TIMESCALE_HOST,
30
- TIMESCALE_PORT,
31
- TIMESCALE_USER,
32
- TIMESCALE_PASSWORD,
33
- TIMESCALE_DATABASE,
34
- TIMESCALE_CONNECTION_LIMIT,
35
- TIMESCALE_MIGRATIONS_DIR,
36
- } = process.env;
37
-
38
- export default {
39
- logColor: 'white',
40
- logMethod: 'db',
41
-
42
- db: {
43
- autosave: DB_AUTO_SAVE ?? 'false', // 'true' (cron interval), 'false' (disabled), 'onUpdate' (save after each write op)
44
- file: DB_FILE ?? 'db.json',
45
- mode: DB_MODE ?? 'file', // 'file' (single db.json) or 'directory' (one file per collection)
46
- directory: DB_DIRECTORY ?? 'db', // directory name for collection files when mode is 'directory'
47
- saveInterval: DB_SAVE_INTERVAL ?? 60 * 60, // 1 hour
48
- schema: DB_SCHEMA_PATH ?? './config/db-schema.js'
49
- },
50
- paths: {
51
- access: ORM_ACCESS_PATH ?? './access', // Optional for restServer access hooks
52
- model: ORM_MODEL_PATH ?? './models',
53
- serializer: ORM_SERIALIZER_PATH ?? './serializers',
54
- transform: ORM_TRANSFORM_PATH ?? './transforms',
55
- view: ORM_VIEW_PATH ?? './views'
56
- },
57
- mysql: MYSQL_HOST ? {
58
- host: MYSQL_HOST ?? 'localhost',
59
- port: parseInt(MYSQL_PORT ?? '3306'),
60
- user: MYSQL_USER ?? 'root',
61
- password: MYSQL_PASSWORD ?? '',
62
- database: MYSQL_DATABASE ?? 'stonyx',
63
- connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
64
- migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
65
- migrationsTable: '__migrations',
66
- } : undefined,
67
- postgres: PG_HOST ? {
68
- host: PG_HOST ?? 'localhost',
69
- port: parseInt(PG_PORT ?? '5432'),
70
- user: PG_USER ?? 'postgres',
71
- password: PG_PASSWORD ?? '',
72
- database: PG_DATABASE ?? 'stonyx',
73
- connectionLimit: parseInt(PG_CONNECTION_LIMIT ?? '10'),
74
- migrationsDir: PG_MIGRATIONS_DIR ?? 'migrations',
75
- migrationsTable: '__migrations',
76
- } : undefined,
77
- timescale: TIMESCALE_HOST ? {
78
- host: TIMESCALE_HOST ?? 'localhost',
79
- port: parseInt(TIMESCALE_PORT ?? '5432'),
80
- user: TIMESCALE_USER ?? 'postgres',
81
- password: TIMESCALE_PASSWORD ?? '',
82
- database: TIMESCALE_DATABASE ?? 'stonyx',
83
- connectionLimit: parseInt(TIMESCALE_CONNECTION_LIMIT ?? '10'),
84
- migrationsDir: TIMESCALE_MIGRATIONS_DIR ?? 'migrations',
85
- migrationsTable: '__migrations',
86
- } : undefined,
87
- restServer: {
88
- enabled: ORM_USE_REST_SERVER ?? 'true', // Whether to load restServer for automatic route setup or
89
- route: ORM_REST_ROUTE ?? '/',
90
- }
91
- }
1
+ // project configuration, override-able by listed environment variables
2
+ const {
3
+ DEBUG,
4
+ NODE_ENV,
5
+ } = process.env;
6
+
7
+ const environment = NODE_ENV ?? 'development';
8
+
9
+ export default {
10
+ environment,
11
+ debug: DEBUG ?? environment === 'development',
12
+ }
@@ -0,0 +1,91 @@
1
+ const {
2
+ ORM_ACCESS_PATH,
3
+ ORM_MODEL_PATH,
4
+ ORM_REST_ROUTE,
5
+ ORM_SERIALIZER_PATH,
6
+ ORM_TRANSFORM_PATH,
7
+ ORM_VIEW_PATH,
8
+ ORM_USE_REST_SERVER,
9
+ DB_AUTO_SAVE,
10
+ DB_FILE,
11
+ DB_MODE,
12
+ DB_DIRECTORY,
13
+ DB_SCHEMA_PATH,
14
+ DB_SAVE_INTERVAL,
15
+ MYSQL_HOST,
16
+ MYSQL_PORT,
17
+ MYSQL_USER,
18
+ MYSQL_PASSWORD,
19
+ MYSQL_DATABASE,
20
+ MYSQL_CONNECTION_LIMIT,
21
+ MYSQL_MIGRATIONS_DIR,
22
+ PG_HOST,
23
+ PG_PORT,
24
+ PG_USER,
25
+ PG_PASSWORD,
26
+ PG_DATABASE,
27
+ PG_CONNECTION_LIMIT,
28
+ PG_MIGRATIONS_DIR,
29
+ TIMESCALE_HOST,
30
+ TIMESCALE_PORT,
31
+ TIMESCALE_USER,
32
+ TIMESCALE_PASSWORD,
33
+ TIMESCALE_DATABASE,
34
+ TIMESCALE_CONNECTION_LIMIT,
35
+ TIMESCALE_MIGRATIONS_DIR,
36
+ } = process.env;
37
+
38
+ export default {
39
+ logColor: 'white',
40
+ logMethod: 'db',
41
+
42
+ db: {
43
+ autosave: DB_AUTO_SAVE ?? 'false', // 'true' (cron interval), 'false' (disabled), 'onUpdate' (save after each write op)
44
+ file: DB_FILE ?? 'db.json',
45
+ mode: DB_MODE ?? 'file', // 'file' (single db.json) or 'directory' (one file per collection)
46
+ directory: DB_DIRECTORY ?? 'db', // directory name for collection files when mode is 'directory'
47
+ saveInterval: DB_SAVE_INTERVAL ?? 60 * 60, // 1 hour
48
+ schema: DB_SCHEMA_PATH ?? './config/db-schema.js'
49
+ },
50
+ paths: {
51
+ access: ORM_ACCESS_PATH ?? './access', // Optional for restServer access hooks
52
+ model: ORM_MODEL_PATH ?? './models',
53
+ serializer: ORM_SERIALIZER_PATH ?? './serializers',
54
+ transform: ORM_TRANSFORM_PATH ?? './transforms',
55
+ view: ORM_VIEW_PATH ?? './views'
56
+ },
57
+ mysql: MYSQL_HOST ? {
58
+ host: MYSQL_HOST ?? 'localhost',
59
+ port: parseInt(MYSQL_PORT ?? '3306'),
60
+ user: MYSQL_USER ?? 'root',
61
+ password: MYSQL_PASSWORD ?? '',
62
+ database: MYSQL_DATABASE ?? 'stonyx',
63
+ connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
64
+ migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
65
+ migrationsTable: '__migrations',
66
+ } : undefined,
67
+ postgres: PG_HOST ? {
68
+ host: PG_HOST ?? 'localhost',
69
+ port: parseInt(PG_PORT ?? '5432'),
70
+ user: PG_USER ?? 'postgres',
71
+ password: PG_PASSWORD ?? '',
72
+ database: PG_DATABASE ?? 'stonyx',
73
+ connectionLimit: parseInt(PG_CONNECTION_LIMIT ?? '10'),
74
+ migrationsDir: PG_MIGRATIONS_DIR ?? 'migrations',
75
+ migrationsTable: '__migrations',
76
+ } : undefined,
77
+ timescale: TIMESCALE_HOST ? {
78
+ host: TIMESCALE_HOST ?? 'localhost',
79
+ port: parseInt(TIMESCALE_PORT ?? '5432'),
80
+ user: TIMESCALE_USER ?? 'postgres',
81
+ password: TIMESCALE_PASSWORD ?? '',
82
+ database: TIMESCALE_DATABASE ?? 'stonyx',
83
+ connectionLimit: parseInt(TIMESCALE_CONNECTION_LIMIT ?? '10'),
84
+ migrationsDir: TIMESCALE_MIGRATIONS_DIR ?? 'migrations',
85
+ migrationsTable: '__migrations',
86
+ } : undefined,
87
+ restServer: {
88
+ enabled: ORM_USE_REST_SERVER ?? 'true', // Whether to load restServer for automatic route setup or
89
+ route: ORM_REST_ROUTE ?? '/',
90
+ }
91
+ }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ import { createRecord, updateRecord } from './manage-record.js';
8
8
  import { count, avg, sum, min, max } from './aggregates.js';
9
9
  export { default } from './main.js';
10
10
  export { store, relationships } from './main.js';
11
+ export type { PersistErrorDetail } from './main.js';
11
12
  export { Model, View, Serializer };
12
13
  export { attr, belongsTo, hasMany, createRecord, updateRecord };
13
14
  export { count, avg, sum, min, max };
package/dist/main.d.ts CHANGED
@@ -15,6 +15,12 @@ export interface OrmDB {
15
15
  save(): Promise<void>;
16
16
  init(): Promise<void>;
17
17
  }
18
+ export interface PersistErrorDetail {
19
+ operation: 'create' | 'update' | 'delete';
20
+ modelName: string;
21
+ recordId: unknown;
22
+ error: Error;
23
+ }
18
24
  export default class Orm {
19
25
  static initialized: boolean;
20
26
  static relationships: Map<string, Map<string, unknown>>;
@@ -29,6 +35,7 @@ export default class Orm {
29
35
  options: OrmOptions;
30
36
  sqlDb?: SqlDb;
31
37
  db?: OrmDB | SqlDb;
38
+ private _persistErrorHandler;
32
39
  constructor(options?: OrmOptions);
33
40
  init(): Promise<void>;
34
41
  startup(): Promise<void>;
@@ -39,6 +46,15 @@ export default class Orm {
39
46
  serializerClass: unknown;
40
47
  };
41
48
  isView(modelName: string): boolean;
49
+ /**
50
+ * Register a callback to be invoked when a fire-and-forget SQL persist fails.
51
+ * Without a handler, persist errors are logged via log.error (backwards-compatible).
52
+ */
53
+ onPersistError(handler: ((detail: PersistErrorDetail) => void) | null): void;
54
+ /**
55
+ * Emit a persist error to the registered handler, or fall back to log.error.
56
+ */
57
+ emitPersistError(detail: PersistErrorDetail): void;
42
58
  warn(message: string): void;
43
59
  }
44
60
  export declare const store: Store;
package/dist/main.js CHANGED
@@ -41,6 +41,7 @@ export default class Orm {
41
41
  options;
42
42
  sqlDb;
43
43
  db;
44
+ _persistErrorHandler = null;
44
45
  constructor(options = {}) {
45
46
  if (Orm.instance)
46
47
  return Orm.instance;
@@ -168,6 +169,31 @@ export default class Orm {
168
169
  const modelClassPrefix = kebabCaseToPascalCase(modelName);
169
170
  return !!this.views[`${modelClassPrefix}View`];
170
171
  }
172
+ /**
173
+ * Register a callback to be invoked when a fire-and-forget SQL persist fails.
174
+ * Without a handler, persist errors are logged via log.error (backwards-compatible).
175
+ */
176
+ onPersistError(handler) {
177
+ this._persistErrorHandler = handler;
178
+ }
179
+ /**
180
+ * Emit a persist error to the registered handler, or fall back to log.error.
181
+ */
182
+ emitPersistError(detail) {
183
+ const fallbackLog = () => log.error?.(`[ORM] Failed to persist ${detail.operation} for ${detail.modelName}:${String(detail.recordId)}: ${detail.error.message}`);
184
+ if (this._persistErrorHandler) {
185
+ try {
186
+ this._persistErrorHandler(detail);
187
+ }
188
+ catch (handlerError) {
189
+ fallbackLog();
190
+ log.error?.(`[ORM] onPersistError handler threw: ${handlerError instanceof Error ? handlerError.message : String(handlerError)}`);
191
+ }
192
+ }
193
+ else {
194
+ fallbackLog();
195
+ }
196
+ }
171
197
  // Queue warnings to avoid the same error from being logged in the same iteration
172
198
  warn(message) {
173
199
  this.warnings.add(message);
@@ -2,12 +2,12 @@ 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 { isOrmRecord } from './utils.js';
5
- import log from 'stonyx/log';
6
5
  const defaultOptions = {
7
6
  isDbRecord: false,
8
7
  serialize: true,
9
8
  transform: true
10
9
  };
10
+ let pendingIdCounter = 0;
11
11
  export function createRecord(modelName, rawData = {}, userOptions = {}) {
12
12
  const orm = Orm.instance;
13
13
  const { initialized } = Orm;
@@ -85,7 +85,12 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
85
85
  if (shouldPersist) {
86
86
  const response = { data: { id: record.id } };
87
87
  orm.sqlDb.persist('create', modelName, { rawData }, response).catch((err) => {
88
- log.error?.(`[ORM] Failed to persist create for ${modelName}:${String(record.id)}`, err);
88
+ orm.emitPersistError({
89
+ operation: 'create',
90
+ modelName,
91
+ recordId: record.id,
92
+ error: err instanceof Error ? err : new Error(String(err)),
93
+ });
89
94
  });
90
95
  }
91
96
  return record;
@@ -107,7 +112,12 @@ export function updateRecord(record, rawData, userOptions = {}) {
107
112
  const shouldPersist = orm?.sqlDb && !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
108
113
  if (shouldPersist && modelName) {
109
114
  orm.sqlDb.persist('update', modelName, { record, oldState }, {}).catch((err) => {
110
- log.error?.(`[ORM] Failed to persist update for ${modelName}:${String(record.id)}`, err);
115
+ orm.emitPersistError({
116
+ operation: 'update',
117
+ modelName,
118
+ recordId: record.id,
119
+ error: err instanceof Error ? err : new Error(String(err)),
120
+ });
111
121
  });
112
122
  }
113
123
  }
@@ -120,9 +130,11 @@ export function updateRecord(record, rawData, userOptions = {}) {
120
130
  function assignRecordId(modelName, rawData) {
121
131
  if (rawData.id)
122
132
  return;
123
- // In SQL mode with numeric IDs, defer to database auto-increment
133
+ // In SQL mode with numeric IDs, defer to database auto-increment.
134
+ // Use unique negative integers — they survive the number transform (parseInt preserves negatives)
135
+ // and avoid NaN store-key collisions that string pending IDs caused.
124
136
  if (Orm.instance?.sqlDb && !isStringIdModel(modelName)) {
125
- rawData.id = `__pending_${Date.now()}_${Math.random()}`;
137
+ rawData.id = -(++pendingIdCounter);
126
138
  rawData.__pendingSqlId = true;
127
139
  return;
128
140
  }
@@ -16,6 +16,7 @@ interface PersistContext {
16
16
  record?: OrmRecord;
17
17
  recordId?: unknown;
18
18
  oldState?: Record<string, unknown>;
19
+ rawData?: Record<string, unknown>;
19
20
  }
20
21
  interface PersistResponse {
21
22
  data?: {
@@ -321,8 +321,10 @@ export default class MysqlDB {
321
321
  if (!record)
322
322
  return;
323
323
  const insertData = this._recordToRow(record, schema);
324
- // For auto-increment models, remove the pending ID
325
- const isPendingId = record.__data.__pendingSqlId;
324
+ // For auto-increment models, remove the pending ID.
325
+ // Check context.rawData (not record.__data) because __pendingSqlId is not a model
326
+ // attribute and gets lost during serialization.
327
+ const isPendingId = context.rawData?.__pendingSqlId === true;
326
328
  if (isPendingId) {
327
329
  delete insertData.id;
328
330
  }
@@ -376,8 +376,10 @@ export default class PostgresDB {
376
376
  if (!record)
377
377
  return;
378
378
  const insertData = this._recordToRow(record, schema, context.rawData);
379
- // For auto-increment models, remove the pending ID
380
- const isPendingId = record.__data.__pendingSqlId;
379
+ // For auto-increment models, remove the pending ID.
380
+ // Check context.rawData (not record.__data) because __pendingSqlId is not a model
381
+ // attribute and gets lost during serialization.
382
+ const isPendingId = context.rawData?.__pendingSqlId === true;
381
383
  if (isPendingId) {
382
384
  delete insertData.id;
383
385
  }
package/dist/store.js CHANGED
@@ -115,7 +115,12 @@ export default class Store {
115
115
  // Auto-persist delete to SQL
116
116
  if (id && Orm.instance?.sqlDb) {
117
117
  Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err) => {
118
- console.error(`[ORM] Failed to persist delete for ${key}:${id}`, err);
118
+ Orm.instance.emitPersistError({
119
+ operation: 'delete',
120
+ modelName: key,
121
+ recordId: id,
122
+ error: err instanceof Error ? err : new Error(String(err)),
123
+ });
119
124
  });
120
125
  }
121
126
  if (id)
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-beta.6",
7
+ "version": "0.3.2-beta.8",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -61,9 +61,9 @@
61
61
  },
62
62
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
63
63
  "dependencies": {
64
- "@stonyx/cron": "0.2.1-beta.29",
64
+ "@stonyx/cron": "0.2.1-beta.38",
65
65
  "@stonyx/events": "0.1.1-beta.9",
66
- "stonyx": "0.2.3-beta.11"
66
+ "stonyx": "0.2.3-beta.53"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "@stonyx/rest-server": ">=0.2.1-beta.14",
@@ -83,17 +83,18 @@
83
83
  },
84
84
  "devDependencies": {
85
85
  "@stonyx/rest-server": "0.2.1-beta.30",
86
- "@stonyx/utils": "0.2.3-beta.7",
86
+ "@stonyx/utils": "0.2.3-beta.23",
87
87
  "@types/node": "^25.6.0",
88
88
  "mysql2": "^3.20.0",
89
89
  "pg": "^8.20.0",
90
90
  "qunit": "^2.24.1",
91
91
  "sinon": "^21.0.0",
92
+ "tsx": "^4.21.0",
92
93
  "typescript": "^5.8.3"
93
94
  },
94
95
  "scripts": {
95
96
  "build": "tsc",
96
97
  "build:test": "tsc -p tsconfig.test.json",
97
- "test": "npm run build && npm run build:test && stonyx test 'dist-test/test/**/*-test.js'"
98
+ "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
98
99
  }
99
100
  }
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import { count, avg, sum, min, max } from './aggregates.js';
26
26
 
27
27
  export { default } from './main.js';
28
28
  export { store, relationships } from './main.js';
29
+ export type { PersistErrorDetail } from './main.js';
29
30
  export { Model, View, Serializer }; // base classes
30
31
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
31
32
  export { count, avg, sum, min, max }; // aggregate helpers
package/src/main.ts CHANGED
@@ -49,6 +49,13 @@ const defaultOptions: OrmOptions = {
49
49
  dbType: 'json'
50
50
  }
51
51
 
52
+ export interface PersistErrorDetail {
53
+ operation: 'create' | 'update' | 'delete';
54
+ modelName: string;
55
+ recordId: unknown;
56
+ error: Error;
57
+ }
58
+
52
59
  export default class Orm {
53
60
  static initialized: boolean = false;
54
61
  static relationships: Map<string, Map<string, unknown>> = new Map();
@@ -65,6 +72,8 @@ export default class Orm {
65
72
  sqlDb?: SqlDb;
66
73
  db?: OrmDB | SqlDb;
67
74
 
75
+ private _persistErrorHandler: ((detail: PersistErrorDetail) => void) | null = null;
76
+
68
77
  constructor(options: OrmOptions = {}) {
69
78
  if (Orm.instance) return Orm.instance;
70
79
 
@@ -214,6 +223,32 @@ export default class Orm {
214
223
  return !!this.views[`${modelClassPrefix}View`];
215
224
  }
216
225
 
226
+ /**
227
+ * Register a callback to be invoked when a fire-and-forget SQL persist fails.
228
+ * Without a handler, persist errors are logged via log.error (backwards-compatible).
229
+ */
230
+ onPersistError(handler: ((detail: PersistErrorDetail) => void) | null): void {
231
+ this._persistErrorHandler = handler;
232
+ }
233
+
234
+ /**
235
+ * Emit a persist error to the registered handler, or fall back to log.error.
236
+ */
237
+ emitPersistError(detail: PersistErrorDetail): void {
238
+ const fallbackLog = () => log.error?.(`[ORM] Failed to persist ${detail.operation} for ${detail.modelName}:${String(detail.recordId)}: ${detail.error.message}`);
239
+
240
+ if (this._persistErrorHandler) {
241
+ try {
242
+ this._persistErrorHandler(detail);
243
+ } catch (handlerError) {
244
+ fallbackLog();
245
+ log.error?.(`[ORM] onPersistError handler threw: ${handlerError instanceof Error ? handlerError.message : String(handlerError)}`);
246
+ }
247
+ } else {
248
+ fallbackLog();
249
+ }
250
+ }
251
+
217
252
  // Queue warnings to avoid the same error from being logged in the same iteration
218
253
  warn(message: string): void {
219
254
  this.warnings.add(message);
@@ -3,7 +3,6 @@ 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
5
  import { isOrmRecord } from './utils.js';
6
- import log from 'stonyx/log';
7
6
 
8
7
  interface CreateRecordOptions {
9
8
  isDbRecord?: boolean;
@@ -27,6 +26,8 @@ const defaultOptions: CreateRecordOptions = {
27
26
  transform: true
28
27
  };
29
28
 
29
+ let pendingIdCounter = 0;
30
+
30
31
  export function createRecord(modelName: string, rawData: { [key: string]: unknown } = {}, userOptions: CreateRecordOptions = {}): OrmRecord {
31
32
  const orm = Orm.instance;
32
33
  const { initialized } = Orm;
@@ -118,7 +119,12 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
118
119
  if (shouldPersist) {
119
120
  const response = { data: { id: record.id } };
120
121
  orm!.sqlDb!.persist('create', modelName, { rawData }, response).catch((err: unknown) => {
121
- log.error?.(`[ORM] Failed to persist create for ${modelName}:${String(record.id)}`, err);
122
+ orm!.emitPersistError({
123
+ operation: 'create',
124
+ modelName,
125
+ recordId: record.id,
126
+ error: err instanceof Error ? err : new Error(String(err)),
127
+ });
122
128
  });
123
129
  }
124
130
 
@@ -146,7 +152,12 @@ export function updateRecord(record: OrmRecord, rawData: unknown, userOptions: C
146
152
  const shouldPersist = orm?.sqlDb && !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
147
153
  if (shouldPersist && modelName) {
148
154
  orm!.sqlDb!.persist('update', modelName, { record, oldState }, {}).catch((err: unknown) => {
149
- log.error?.(`[ORM] Failed to persist update for ${modelName}:${String(record.id)}`, err);
155
+ orm!.emitPersistError({
156
+ operation: 'update',
157
+ modelName,
158
+ recordId: record.id,
159
+ error: err instanceof Error ? err : new Error(String(err)),
160
+ });
150
161
  });
151
162
  }
152
163
  }
@@ -160,9 +171,11 @@ export function updateRecord(record: OrmRecord, rawData: unknown, userOptions: C
160
171
  function assignRecordId(modelName: string, rawData: { [key: string]: unknown }): void {
161
172
  if (rawData.id) return;
162
173
 
163
- // In SQL mode with numeric IDs, defer to database auto-increment
174
+ // In SQL mode with numeric IDs, defer to database auto-increment.
175
+ // Use unique negative integers — they survive the number transform (parseInt preserves negatives)
176
+ // and avoid NaN store-key collisions that string pending IDs caused.
164
177
  if (Orm.instance?.sqlDb && !isStringIdModel(modelName)) {
165
- rawData.id = `__pending_${Date.now()}_${Math.random()}`;
178
+ rawData.id = -(++pendingIdCounter);
166
179
  rawData.__pendingSqlId = true;
167
180
  return;
168
181
  }
@@ -21,6 +21,7 @@ interface PersistContext {
21
21
  record?: OrmRecord;
22
22
  recordId?: unknown;
23
23
  oldState?: Record<string, unknown>;
24
+ rawData?: Record<string, unknown>;
24
25
  }
25
26
 
26
27
  interface PersistResponse {
@@ -420,8 +421,10 @@ export default class MysqlDB {
420
421
 
421
422
  const insertData = this._recordToRow(record, schema);
422
423
 
423
- // For auto-increment models, remove the pending ID
424
- const isPendingId = record.__data.__pendingSqlId;
424
+ // For auto-increment models, remove the pending ID.
425
+ // Check context.rawData (not record.__data) because __pendingSqlId is not a model
426
+ // attribute and gets lost during serialization.
427
+ const isPendingId = context.rawData?.__pendingSqlId === true;
425
428
 
426
429
  if (isPendingId) {
427
430
  delete insertData.id;
@@ -494,8 +494,10 @@ export default class PostgresDB {
494
494
 
495
495
  const insertData = this._recordToRow(record, schema, context.rawData);
496
496
 
497
- // For auto-increment models, remove the pending ID
498
- const isPendingId = record.__data.__pendingSqlId;
497
+ // For auto-increment models, remove the pending ID.
498
+ // Check context.rawData (not record.__data) because __pendingSqlId is not a model
499
+ // attribute and gets lost during serialization.
500
+ const isPendingId = context.rawData?.__pendingSqlId === true;
499
501
 
500
502
  if (isPendingId) {
501
503
  delete insertData.id;
package/src/store.ts CHANGED
@@ -179,7 +179,12 @@ export default class Store {
179
179
  // Auto-persist delete to SQL
180
180
  if (id && Orm.instance?.sqlDb) {
181
181
  Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err: unknown) => {
182
- console.error(`[ORM] Failed to persist delete for ${key}:${id}`, err);
182
+ Orm.instance.emitPersistError({
183
+ operation: 'delete',
184
+ modelName: key,
185
+ recordId: id,
186
+ error: err instanceof Error ? err : new Error(String(err)),
187
+ });
183
188
  });
184
189
  }
185
190