@stonyx/orm 0.3.2-alpha.31 → 0.3.2-alpha.33

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.
@@ -101,6 +101,16 @@ export default class DynamoDBDB {
101
101
  */
102
102
  startup(): Promise<void>;
103
103
  shutdown(): Promise<void>;
104
+ /**
105
+ * DynamoDB does NOT use write serialization (#156).
106
+ *
107
+ * Unlike MySQL/PostgreSQL, DynamoDB has no server-side foreign key
108
+ * constraints and no multi-row transactions in standard single-item
109
+ * operations (PutItem, UpdateItem, DeleteItem). Each operation is
110
+ * atomic at the item level and cannot deadlock against other items.
111
+ * Concurrent fire-and-forget writes therefore cannot produce the
112
+ * cross-row lock contention that causes InnoDB/PG deadlocks.
113
+ */
104
114
  persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void>;
105
115
  findRecord(modelName: string, id: unknown): Promise<OrmRecord | undefined>;
106
116
  findAll(modelName: string, conditions?: Record<string, unknown>): Promise<OrmRecord[]>;
@@ -191,6 +191,16 @@ export default class DynamoDBDB {
191
191
  // -------------------------------------------------------------------------
192
192
  // SqlDb contract — persist
193
193
  // -------------------------------------------------------------------------
194
+ /**
195
+ * DynamoDB does NOT use write serialization (#156).
196
+ *
197
+ * Unlike MySQL/PostgreSQL, DynamoDB has no server-side foreign key
198
+ * constraints and no multi-row transactions in standard single-item
199
+ * operations (PutItem, UpdateItem, DeleteItem). Each operation is
200
+ * atomic at the item level and cannot deadlock against other items.
201
+ * Concurrent fire-and-forget writes therefore cannot produce the
202
+ * cross-row lock contention that causes InnoDB/PG deadlocks.
203
+ */
194
204
  async persist(operation, modelName, context, response) {
195
205
  const OrmModule = await this._getOrm();
196
206
  if (OrmModule.default?.instance?.isView?.(modelName))
@@ -61,6 +61,14 @@ export default class MysqlDB {
61
61
  deps: MysqlDBDeps;
62
62
  pool: Pool | null;
63
63
  mysqlConfig: MysqlConfig;
64
+ /**
65
+ * Promise-chain mutex for write serialization (#156).
66
+ * All persist() calls chain through this single queue so concurrent
67
+ * fire-and-forget writes never produce parallel InnoDB transactions
68
+ * on FK-linked rows (which cause deadlocks).
69
+ * Reads are NOT affected — only persist() serializes.
70
+ */
71
+ private _writeQueue;
64
72
  constructor(deps?: Partial<MysqlDBDeps>);
65
73
  private requirePool;
66
74
  init(): Promise<void>;
@@ -26,6 +26,14 @@ export default class MysqlDB {
26
26
  deps;
27
27
  pool;
28
28
  mysqlConfig;
29
+ /**
30
+ * Promise-chain mutex for write serialization (#156).
31
+ * All persist() calls chain through this single queue so concurrent
32
+ * fire-and-forget writes never produce parallel InnoDB transactions
33
+ * on FK-linked rows (which cause deadlocks).
34
+ * Reads are NOT affected — only persist() serializes.
35
+ */
36
+ _writeQueue = Promise.resolve();
29
37
  constructor(deps = {}) {
30
38
  if (MysqlDB.instance)
31
39
  return MysqlDB.instance;
@@ -302,14 +310,20 @@ export default class MysqlDB {
302
310
  const Orm = (await import('@stonyx/orm')).default;
303
311
  if (Orm.instance?.isView?.(modelName))
304
312
  return;
305
- switch (operation) {
306
- case 'create':
307
- return this._persistCreate(modelName, context, response);
308
- case 'update':
309
- return this._persistUpdate(modelName, context, response);
310
- case 'delete':
311
- return this._persistDelete(modelName, context);
312
- }
313
+ const work = async () => {
314
+ switch (operation) {
315
+ case 'create':
316
+ return this._persistCreate(modelName, context, response);
317
+ case 'update':
318
+ return this._persistUpdate(modelName, context, response);
319
+ case 'delete':
320
+ return this._persistDelete(modelName, context);
321
+ }
322
+ };
323
+ // Chain through the write queue — .then(work, work) ensures the queue
324
+ // advances even when a previous persist rejects (#156).
325
+ this._writeQueue = this._writeQueue.then(work, work);
326
+ return this._writeQueue;
313
327
  }
314
328
  async _persistCreate(modelName, context, response) {
315
329
  const schemas = this.deps.introspectModels();
@@ -289,7 +289,7 @@ export default class OrmRequest extends Request {
289
289
  return { data: record.toJSON?.() };
290
290
  };
291
291
  const deleteHandler = ({ params }) => {
292
- store.remove(model, getId(params));
292
+ store.remove(model, getId(params), { _skipAutoPersist: true });
293
293
  return 204;
294
294
  };
295
295
  // Wrap handlers with hooks
@@ -348,9 +348,13 @@ export default class OrmRequest extends Request {
348
348
  }
349
349
  // Execute main handler
350
350
  const response = await handler(request, state);
351
- // Persist to SQL database for create/update (delete is handled by store.remove auto-persist)
351
+ // Set context.record for update BEFORE persist so SQL drivers can read it
352
+ if (operation === 'update' && response?.data) {
353
+ context.record = store.get(this.model, getId(request.params));
354
+ }
355
+ // Persist to SQL database for all write operations (create/update/delete)
352
356
  const sqlDb = Orm.instance.sqlDb;
353
- if (sqlDb && (operation === 'create' || operation === 'update')) {
357
+ if (sqlDb && WRITE_OPERATIONS.has(operation)) {
354
358
  await sqlDb.persist(operation, this.model, context, response);
355
359
  }
356
360
  // Add response and relevant records to context
@@ -367,9 +371,6 @@ export default class OrmRequest extends Request {
367
371
  const recordId = isNaN(responseData.id) ? responseData.id : parseInt(responseData.id);
368
372
  context.record = store.get(this.model, recordId);
369
373
  }
370
- else if (operation === 'update' && response?.data) {
371
- context.record = store.get(this.model, getId(request.params));
372
- }
373
374
  else if (operation === 'delete') {
374
375
  // For delete, the record may no longer exist, but we have oldState
375
376
  context.recordId = getId(request.params);
@@ -72,6 +72,14 @@ export default class PostgresDB {
72
72
  deps: PostgresDeps;
73
73
  pool: Pool | null;
74
74
  pgConfig: Record<string, unknown>;
75
+ /**
76
+ * Promise-chain mutex for write serialization (#156).
77
+ * All persist() calls chain through this single queue so concurrent
78
+ * fire-and-forget writes never produce parallel transactions
79
+ * on FK-linked rows (which cause deadlocks).
80
+ * Reads are NOT affected — only persist() serializes.
81
+ */
82
+ private _writeQueue;
75
83
  constructor(deps?: Partial<PostgresDeps>);
76
84
  protected requirePool(): Pool;
77
85
  init(): Promise<void>;
@@ -30,6 +30,14 @@ export default class PostgresDB {
30
30
  deps;
31
31
  pool;
32
32
  pgConfig;
33
+ /**
34
+ * Promise-chain mutex for write serialization (#156).
35
+ * All persist() calls chain through this single queue so concurrent
36
+ * fire-and-forget writes never produce parallel transactions
37
+ * on FK-linked rows (which cause deadlocks).
38
+ * Reads are NOT affected — only persist() serializes.
39
+ */
40
+ _writeQueue = Promise.resolve();
33
41
  constructor(deps = {}) {
34
42
  const Ctor = this.constructor;
35
43
  if (Ctor.instance)
@@ -356,14 +364,20 @@ export default class PostgresDB {
356
364
  const Orm = (await import('@stonyx/orm')).default;
357
365
  if (Orm.instance?.isView?.(modelName))
358
366
  return;
359
- switch (operation) {
360
- case 'create':
361
- return this._persistCreate(modelName, context, response);
362
- case 'update':
363
- return this._persistUpdate(modelName, context, response);
364
- case 'delete':
365
- return this._persistDelete(modelName, context);
366
- }
367
+ const work = async () => {
368
+ switch (operation) {
369
+ case 'create':
370
+ return this._persistCreate(modelName, context, response);
371
+ case 'update':
372
+ return this._persistUpdate(modelName, context, response);
373
+ case 'delete':
374
+ return this._persistDelete(modelName, context);
375
+ }
376
+ };
377
+ // Chain through the write queue — .then(work, work) ensures the queue
378
+ // advances even when a previous persist rejects (#156).
379
+ this._writeQueue = this._writeQueue.then(work, work);
380
+ return this._writeQueue;
367
381
  }
368
382
  async _persistCreate(modelName, context, response) {
369
383
  const schemas = this.deps.introspectModels();
package/dist/store.d.ts CHANGED
@@ -45,7 +45,9 @@ export default class Store {
45
45
  */
46
46
  private _isMemoryModel;
47
47
  set(key: string, value: Map<number | string, unknown>): void;
48
- remove(key: string, id?: number | string): void;
48
+ remove(key: string, id?: number | string, options?: {
49
+ _skipAutoPersist?: boolean;
50
+ }): void;
49
51
  /**
50
52
  * Evict a record from the store with full relationship registry cleanup,
51
53
  * WITHOUT calling record.clean(). This preserves the caller's reference
package/dist/store.js CHANGED
@@ -107,13 +107,14 @@ export default class Store {
107
107
  set(key, value) {
108
108
  this.data.set(key, value);
109
109
  }
110
- remove(key, id) {
110
+ remove(key, id, options) {
111
111
  // Guard: read-only views cannot have records removed
112
112
  if (Orm.instance?.isView?.(key)) {
113
113
  throw new Error(`Cannot remove records from read-only view '${key}'`);
114
114
  }
115
- // Auto-persist delete to SQL
116
- if (id && Orm.instance?.sqlDb) {
115
+ // Auto-persist delete to SQL (fire-and-forget) — skipped when the
116
+ // request path handles persist itself to avoid double-delete.
117
+ if (id && Orm.instance?.sqlDb && !options?._skipAutoPersist) {
117
118
  Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err) => {
118
119
  Orm.instance.emitPersistError({
119
120
  operation: 'delete',
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.31",
7
+ "version": "0.3.2-alpha.33",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -319,6 +319,16 @@ export default class DynamoDBDB {
319
319
  // SqlDb contract — persist
320
320
  // -------------------------------------------------------------------------
321
321
 
322
+ /**
323
+ * DynamoDB does NOT use write serialization (#156).
324
+ *
325
+ * Unlike MySQL/PostgreSQL, DynamoDB has no server-side foreign key
326
+ * constraints and no multi-row transactions in standard single-item
327
+ * operations (PutItem, UpdateItem, DeleteItem). Each operation is
328
+ * atomic at the item level and cannot deadlock against other items.
329
+ * Concurrent fire-and-forget writes therefore cannot produce the
330
+ * cross-row lock contention that causes InnoDB/PG deadlocks.
331
+ */
322
332
  async persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
323
333
  const OrmModule = await this._getOrm();
324
334
  if (OrmModule.default?.instance?.isView?.(modelName)) return;
@@ -84,6 +84,15 @@ export default class MysqlDB {
84
84
  pool!: Pool | null;
85
85
  mysqlConfig!: MysqlConfig;
86
86
 
87
+ /**
88
+ * Promise-chain mutex for write serialization (#156).
89
+ * All persist() calls chain through this single queue so concurrent
90
+ * fire-and-forget writes never produce parallel InnoDB transactions
91
+ * on FK-linked rows (which cause deadlocks).
92
+ * Reads are NOT affected — only persist() serializes.
93
+ */
94
+ private _writeQueue: Promise<void> = Promise.resolve();
95
+
87
96
  constructor(deps: Partial<MysqlDBDeps> = {}) {
88
97
  if (MysqlDB.instance) return MysqlDB.instance;
89
98
  MysqlDB.instance = this;
@@ -398,14 +407,21 @@ export default class MysqlDB {
398
407
  const Orm = (await import('@stonyx/orm')).default;
399
408
  if ((Orm as unknown as { instance?: { isView?: (name: string) => boolean } }).instance?.isView?.(modelName)) return;
400
409
 
401
- switch (operation) {
402
- case 'create':
403
- return this._persistCreate(modelName, context, response);
404
- case 'update':
405
- return this._persistUpdate(modelName, context, response);
406
- case 'delete':
407
- return this._persistDelete(modelName, context);
408
- }
410
+ const work = async () => {
411
+ switch (operation) {
412
+ case 'create':
413
+ return this._persistCreate(modelName, context, response);
414
+ case 'update':
415
+ return this._persistUpdate(modelName, context, response);
416
+ case 'delete':
417
+ return this._persistDelete(modelName, context);
418
+ }
419
+ };
420
+
421
+ // Chain through the write queue — .then(work, work) ensures the queue
422
+ // advances even when a previous persist rejects (#156).
423
+ this._writeQueue = this._writeQueue.then(work, work);
424
+ return this._writeQueue;
409
425
  }
410
426
 
411
427
  private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
@@ -376,7 +376,7 @@ export default class OrmRequest extends Request {
376
376
  };
377
377
 
378
378
  const deleteHandler: HandlerFn = ({ params }) => {
379
- store.remove(model, getId(params));
379
+ store.remove(model, getId(params), { _skipAutoPersist: true });
380
380
  return 204;
381
381
  };
382
382
 
@@ -443,9 +443,14 @@ export default class OrmRequest extends Request {
443
443
  // Execute main handler
444
444
  const response = await handler(request, state);
445
445
 
446
- // Persist to SQL database for create/update (delete is handled by store.remove auto-persist)
446
+ // Set context.record for update BEFORE persist so SQL drivers can read it
447
+ if (operation === 'update' && (response as JsonApiResponse)?.data) {
448
+ context.record = store.get(this.model, getId(request.params));
449
+ }
450
+
451
+ // Persist to SQL database for all write operations (create/update/delete)
447
452
  const sqlDb = Orm.instance.sqlDb;
448
- if (sqlDb && (operation === 'create' || operation === 'update')) {
453
+ if (sqlDb && WRITE_OPERATIONS.has(operation)) {
449
454
  await sqlDb.persist(operation, this.model, context, response);
450
455
  }
451
456
 
@@ -461,8 +466,6 @@ export default class OrmRequest extends Request {
461
466
  const responseData = (response as { data: { id: string | number } }).data;
462
467
  const recordId = isNaN(responseData.id as unknown as number) ? responseData.id : parseInt(responseData.id as string);
463
468
  context.record = store.get(this.model, recordId);
464
- } else if (operation === 'update' && (response as JsonApiResponse)?.data) {
465
- context.record = store.get(this.model, getId(request.params));
466
469
  } else if (operation === 'delete') {
467
470
  // For delete, the record may no longer exist, but we have oldState
468
471
  context.recordId = getId(request.params);
@@ -90,6 +90,15 @@ export default class PostgresDB {
90
90
  pool!: Pool | null;
91
91
  pgConfig!: Record<string, unknown>;
92
92
 
93
+ /**
94
+ * Promise-chain mutex for write serialization (#156).
95
+ * All persist() calls chain through this single queue so concurrent
96
+ * fire-and-forget writes never produce parallel transactions
97
+ * on FK-linked rows (which cause deadlocks).
98
+ * Reads are NOT affected — only persist() serializes.
99
+ */
100
+ private _writeQueue: Promise<void> = Promise.resolve();
101
+
93
102
  constructor(deps: Partial<PostgresDeps> = {}) {
94
103
  const Ctor = this.constructor as typeof PostgresDB;
95
104
  if (Ctor.instance) return Ctor.instance;
@@ -468,14 +477,21 @@ export default class PostgresDB {
468
477
  const Orm = (await import('@stonyx/orm')).default;
469
478
  if ((Orm.instance as { isView?: (name: string) => boolean })?.isView?.(modelName)) return;
470
479
 
471
- switch (operation) {
472
- case 'create':
473
- return this._persistCreate(modelName, context, response);
474
- case 'update':
475
- return this._persistUpdate(modelName, context, response);
476
- case 'delete':
477
- return this._persistDelete(modelName, context);
478
- }
480
+ const work = async () => {
481
+ switch (operation) {
482
+ case 'create':
483
+ return this._persistCreate(modelName, context, response);
484
+ case 'update':
485
+ return this._persistUpdate(modelName, context, response);
486
+ case 'delete':
487
+ return this._persistDelete(modelName, context);
488
+ }
489
+ };
490
+
491
+ // Chain through the write queue — .then(work, work) ensures the queue
492
+ // advances even when a previous persist rejects (#156).
493
+ this._writeQueue = this._writeQueue.then(work, work);
494
+ return this._writeQueue;
479
495
  }
480
496
 
481
497
  private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
package/src/store.ts CHANGED
@@ -170,14 +170,15 @@ export default class Store {
170
170
  this.data.set(key, value);
171
171
  }
172
172
 
173
- remove(key: string, id?: number | string): void {
173
+ remove(key: string, id?: number | string, options?: { _skipAutoPersist?: boolean }): void {
174
174
  // Guard: read-only views cannot have records removed
175
175
  if (Orm.instance?.isView?.(key)) {
176
176
  throw new Error(`Cannot remove records from read-only view '${key}'`);
177
177
  }
178
178
 
179
- // Auto-persist delete to SQL
180
- if (id && Orm.instance?.sqlDb) {
179
+ // Auto-persist delete to SQL (fire-and-forget) — skipped when the
180
+ // request path handles persist itself to avoid double-delete.
181
+ if (id && Orm.instance?.sqlDb && !options?._skipAutoPersist) {
181
182
  Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err: unknown) => {
182
183
  Orm.instance.emitPersistError({
183
184
  operation: 'delete',