@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.
- package/dist/dynamodb/dynamodb-db.d.ts +10 -0
- package/dist/dynamodb/dynamodb-db.js +10 -0
- package/dist/mysql/mysql-db.d.ts +8 -0
- package/dist/mysql/mysql-db.js +22 -8
- package/dist/orm-request.js +7 -6
- package/dist/postgres/postgres-db.d.ts +8 -0
- package/dist/postgres/postgres-db.js +22 -8
- package/dist/store.d.ts +3 -1
- package/dist/store.js +4 -3
- package/package.json +1 -1
- package/src/dynamodb/dynamodb-db.ts +10 -0
- package/src/mysql/mysql-db.ts +24 -8
- package/src/orm-request.ts +8 -5
- package/src/postgres/postgres-db.ts +24 -8
- package/src/store.ts +4 -3
|
@@ -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))
|
package/dist/mysql/mysql-db.d.ts
CHANGED
|
@@ -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>;
|
package/dist/mysql/mysql-db.js
CHANGED
|
@@ -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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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();
|
package/dist/orm-request.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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;
|
package/src/mysql/mysql-db.ts
CHANGED
|
@@ -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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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> {
|
package/src/orm-request.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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',
|