@stonyx/orm 0.3.2-alpha.124 → 0.3.2-alpha.126

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.
@@ -4,7 +4,7 @@ import { ensureMigrationsTable, getAppliedMigrations, getMigrationFiles, applyMi
4
4
  import { introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot } from './schema-introspector.js';
5
5
  import type { ModelSchema, ViewSchema, SnapshotEntry } from './schema-introspector.js';
6
6
  import { loadLatestSnapshot, detectSchemaDrift } from './migration-generator.js';
7
- import { buildInsert, buildUpdate, buildDelete, buildSelect } from './query-builder.js';
7
+ import { buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect } from './query-builder.js';
8
8
  import { store } from '@stonyx/orm';
9
9
  import { createRecord } from '../manage-record.js';
10
10
  import { confirm } from '@stonyx/utils/prompt';
@@ -32,6 +32,43 @@ interface ExecuteResult {
32
32
  insertId?: number;
33
33
  }
34
34
 
35
+ interface QueuedWrite {
36
+ operation: string;
37
+ modelName: string;
38
+ context: PersistContext;
39
+ response: PersistResponse;
40
+ resolve: () => void;
41
+ reject: (error: unknown) => void;
42
+ }
43
+
44
+ interface PreparedCreate {
45
+ write: QueuedWrite;
46
+ modelName: string;
47
+ record: OrmRecord;
48
+ row: Record<string, unknown>;
49
+ isPendingId: boolean;
50
+ }
51
+
52
+ type ExitEmitter = Pick<NodeJS.Process, 'once' | 'removeListener'>;
53
+
54
+ const MYSQL_MAX_PLACEHOLDERS = 65535;
55
+ const DEFAULT_WRITE_BATCH_SIZE = 500;
56
+ const DEFAULT_WRITE_QUEUE_LIMIT = 10000;
57
+
58
+ export class WriteQueueFullError extends Error {
59
+ constructor(limit: number) {
60
+ super(`MysqlDB write queue is full (${limit} queued writes, writeQueueLimit); the write was rejected`);
61
+ this.name = 'WriteQueueFullError';
62
+ }
63
+ }
64
+
65
+ export class WriteQueueClosedError extends Error {
66
+ constructor() {
67
+ super('MysqlDB is shutting down; the write was rejected');
68
+ this.name = 'WriteQueueClosedError';
69
+ }
70
+ }
71
+
35
72
  interface OrmStore {
36
73
  get(key: string): Map<number | string, unknown> | undefined;
37
74
  get(key: string, id: number | string): unknown;
@@ -54,6 +91,7 @@ interface MysqlDBDeps {
54
91
  loadLatestSnapshot: typeof loadLatestSnapshot;
55
92
  detectSchemaDrift: typeof detectSchemaDrift;
56
93
  buildInsert: typeof buildInsert;
94
+ buildInsertMany: typeof buildInsertMany;
57
95
  buildUpdate: typeof buildUpdate;
58
96
  buildDelete: typeof buildDelete;
59
97
  buildSelect: typeof buildSelect;
@@ -65,6 +103,7 @@ interface MysqlDBDeps {
65
103
  config: typeof config;
66
104
  log: Record<string, ((...args: unknown[]) => void) | undefined>;
67
105
  path: typeof path;
106
+ process: ExitEmitter;
68
107
  }
69
108
 
70
109
  const defaultDeps: MysqlDBDeps = {
@@ -72,9 +111,9 @@ const defaultDeps: MysqlDBDeps = {
72
111
  getMigrationFiles, applyMigration, parseMigrationFile,
73
112
  introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
74
113
  loadLatestSnapshot, detectSchemaDrift,
75
- buildInsert, buildUpdate, buildDelete, buildSelect,
114
+ buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect,
76
115
  createRecord, store: store as OrmStore, confirm, readFile, getPluralName,
77
- config, log, path
116
+ config, log, path, process
78
117
  };
79
118
 
80
119
  export default class MysqlDB {
@@ -84,14 +123,12 @@ export default class MysqlDB {
84
123
  pool!: Pool | null;
85
124
  mysqlConfig!: MysqlConfig;
86
125
 
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();
126
+ readonly persistResolvesAfterWrite = true;
127
+ private _queuedWrites: QueuedWrite[] = [];
128
+ private _inFlightWrites = new Set<QueuedWrite>();
129
+ private _drainingWrites: Promise<void> | null = null;
130
+ private _acceptingWrites = true;
131
+ private _exitReport: (() => void) | null = null;
95
132
 
96
133
  constructor(deps: Partial<MysqlDBDeps> = {}) {
97
134
  if (MysqlDB.instance) return MysqlDB.instance;
@@ -113,6 +150,20 @@ export default class MysqlDB {
113
150
  this.pool = await this.deps.getPool(this.mysqlConfig);
114
151
  await this.deps.ensureMigrationsTable(this.pool, this.mysqlConfig.migrationsTable);
115
152
  await this.loadMemoryRecords();
153
+ this._acceptingWrites = true;
154
+ this._registerExitReport();
155
+ }
156
+
157
+ get writeBatchSize(): number {
158
+ return Number(this.mysqlConfig?.writeBatchSize) || DEFAULT_WRITE_BATCH_SIZE;
159
+ }
160
+
161
+ get writeQueueLimit(): number {
162
+ return Number(this.mysqlConfig?.writeQueueLimit) || DEFAULT_WRITE_QUEUE_LIMIT;
163
+ }
164
+
165
+ get pendingWriteCount(): number {
166
+ return this._inFlightWrites.size + this._queuedWrites.length;
116
167
  }
117
168
 
118
169
  async startup(): Promise<void> {
@@ -200,10 +251,36 @@ export default class MysqlDB {
200
251
  }
201
252
 
202
253
  async shutdown(): Promise<void> {
254
+ this._acceptingWrites = false;
255
+ while (this._drainingWrites) await this._drainingWrites;
256
+ this._unregisterExitReport();
203
257
  await this.deps.closePool();
204
258
  this.pool = null;
205
259
  }
206
260
 
261
+ private _registerExitReport(): void {
262
+ if (this._exitReport) return;
263
+ this._exitReport = () => this._reportUnpersistedWrites();
264
+ this.deps.process.once('exit', this._exitReport);
265
+ }
266
+
267
+ private _unregisterExitReport(): void {
268
+ if (!this._exitReport) return;
269
+ this.deps.process.removeListener('exit', this._exitReport);
270
+ this._exitReport = null;
271
+ }
272
+
273
+ private _reportUnpersistedWrites(): void {
274
+ const lostWrites = [...this._inFlightWrites, ...this._queuedWrites];
275
+ if (lostWrites.length === 0) return;
276
+
277
+ const lostByModel = new Map<string, number>();
278
+ for (const { modelName } of lostWrites) lostByModel.set(modelName, (lostByModel.get(modelName) ?? 0) + 1);
279
+ const breakdown = [...lostByModel].map(([modelName, count]) => `${modelName}: ${count}`).join(', ');
280
+
281
+ this.deps.log.error?.(`[MysqlDB] Process exiting with ${lostWrites.length} queued write(s) not persisted; they are lost (${breakdown})`);
282
+ }
283
+
207
284
  async save(): Promise<void> {
208
285
  // No-op: MySQL persists data immediately via persist()
209
286
  }
@@ -419,76 +496,185 @@ export default class MysqlDB {
419
496
  }
420
497
 
421
498
  async persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
422
- // Views are read-only — no-op for all write operations
423
499
  const Orm = (await import('@stonyx/orm')).default;
424
500
  if ((Orm as unknown as { instance?: { isView?: (name: string) => boolean } }).instance?.isView?.(modelName)) return;
425
501
 
426
- const work = async () => {
427
- switch (operation) {
428
- case 'create':
429
- return this._persistCreate(modelName, context, response);
430
- case 'update':
431
- return this._persistUpdate(modelName, context, response);
432
- case 'delete':
433
- return this._persistDelete(modelName, context);
502
+ if (!this._acceptingWrites) throw new WriteQueueClosedError();
503
+ if (this._queuedWrites.length >= this.writeQueueLimit) throw new WriteQueueFullError(this.writeQueueLimit);
504
+
505
+ return new Promise<void>((resolve, reject) => {
506
+ const write: QueuedWrite = {
507
+ operation, modelName, context, response,
508
+ resolve: () => {
509
+ this._inFlightWrites.delete(write);
510
+ resolve();
511
+ },
512
+ reject: (error: unknown) => {
513
+ this._inFlightWrites.delete(write);
514
+ reject(error);
515
+ },
516
+ };
517
+ this._queuedWrites.push(write);
518
+ this._drainWritesInBackground();
519
+ });
520
+ }
521
+
522
+ private _drainWritesInBackground(): void {
523
+ if (this._drainingWrites) return;
524
+ this._drainingWrites = this._drainQueuedWrites().finally(() => {
525
+ this._drainingWrites = null;
526
+ });
527
+ }
528
+
529
+ private async _drainQueuedWrites(): Promise<void> {
530
+ while (this._queuedWrites.length > 0) {
531
+ const writes = this._takeNextWriteGroup();
532
+ writes.forEach(write => this._inFlightWrites.add(write));
533
+ try {
534
+ await this._executeWriteGroup(writes);
535
+ } catch (error) {
536
+ writes.filter(write => this._inFlightWrites.has(write)).forEach(write => write.reject(error));
434
537
  }
435
- };
538
+ }
539
+ }
540
+
541
+ private _takeNextWriteGroup(): QueuedWrite[] {
542
+ const [first] = this._queuedWrites;
543
+ let groupSize = 1;
544
+
545
+ if (first.operation === 'create') {
546
+ while (
547
+ groupSize < this.writeBatchSize
548
+ && groupSize < this._queuedWrites.length
549
+ && this._queuedWrites[groupSize].operation === 'create'
550
+ && this._queuedWrites[groupSize].modelName === first.modelName
551
+ ) groupSize++;
552
+ }
436
553
 
437
- // Chain through the write queue — .then(work, work) ensures the queue
438
- // advances even when a previous persist rejects (#156).
439
- this._writeQueue = this._writeQueue.then(work, work);
440
- return this._writeQueue;
554
+ return this._queuedWrites.splice(0, groupSize);
441
555
  }
442
556
 
443
- private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
444
- const schemas = this.deps.introspectModels();
445
- const schema = schemas[modelName];
557
+ private async _executeWriteGroup(writes: QueuedWrite[]): Promise<void> {
558
+ const [first] = writes;
559
+ if (first.operation === 'create') return this._persistCreates(first.modelName, writes);
446
560
 
447
- if (!schema) return;
561
+ try {
562
+ if (first.operation === 'update') await this._persistUpdate(first.modelName, first.context, first.response);
563
+ if (first.operation === 'delete') await this._persistDelete(first.modelName, first.context);
564
+ first.resolve();
565
+ } catch (error) {
566
+ first.reject(error);
567
+ }
568
+ }
569
+
570
+ private async _persistCreates(modelName: string, writes: QueuedWrite[]): Promise<void> {
571
+ const schema = this.deps.introspectModels()[modelName];
572
+ const prepared: PreparedCreate[] = [];
573
+
574
+ for (const write of writes) {
575
+ try {
576
+ const create = schema ? this._prepareCreate(modelName, schema, write) : null;
577
+ if (create) prepared.push(create);
578
+ else write.resolve();
579
+ } catch (error) {
580
+ write.reject(error);
581
+ }
582
+ }
583
+
584
+ for (const chunk of this._chunkForSingleInsert(prepared)) {
585
+ await this._insertCreates(schema, chunk);
586
+ }
587
+ }
448
588
 
589
+ private _prepareCreate(modelName: string, schema: ModelSchema, write: QueuedWrite): PreparedCreate | null {
590
+ const { context, response } = write;
449
591
  const recordId = response?.data?.id;
450
592
  const record = recordId != null ? this.deps.store.get(modelName, (isNaN(recordId as number) ? recordId : parseInt(recordId as string)) as number | string) as OrmRecord | null : null;
451
593
 
452
- if (!record) return;
453
-
454
- const insertData = this._recordToRow(record, schema);
594
+ if (!record) return null;
455
595
 
456
- // For auto-increment models, remove the pending ID.
457
- // Check context.rawData (not record.__data) because __pendingSqlId is not a model
458
- // attribute and gets lost during serialization.
596
+ const row = this._recordToRow(record, schema);
459
597
  const isPendingId = context.rawData?.__pendingSqlId === true;
460
598
 
461
- if (isPendingId) {
462
- delete insertData.id;
463
- } else if (insertData.id !== undefined) {
464
- // Keep user-provided ID (string IDs or explicit numeric IDs)
599
+ if (isPendingId) delete row.id;
600
+
601
+ return { write, modelName, record, row, isPendingId };
602
+ }
603
+
604
+ private _chunkForSingleInsert(prepared: PreparedCreate[]): PreparedCreate[][] {
605
+ const chunks: PreparedCreate[][] = [];
606
+ let chunk: PreparedCreate[] = [];
607
+ let chunkColumns = new Set<string>();
608
+
609
+ for (const create of prepared) {
610
+ const columnsWithCreate = new Set([...chunkColumns, ...Object.keys(create.row)]);
611
+ const mixesIdSources = chunk.length > 0 && chunk[0].isPendingId !== create.isPendingId;
612
+ const exceedsPlaceholders = (chunk.length + 1) * columnsWithCreate.size > MYSQL_MAX_PLACEHOLDERS;
613
+
614
+ if (chunk.length > 0 && (mixesIdSources || exceedsPlaceholders)) {
615
+ chunks.push(chunk);
616
+ chunk = [];
617
+ chunkColumns = new Set(Object.keys(create.row));
618
+ } else {
619
+ chunkColumns = columnsWithCreate;
620
+ }
621
+
622
+ chunk.push(create);
465
623
  }
466
624
 
467
- const { sql, values } = this.deps.buildInsert(schema.table, insertData);
625
+ if (chunk.length > 0) chunks.push(chunk);
626
+ return chunks;
627
+ }
468
628
 
469
- const [result] = await this.requirePool().execute(sql, values) as [ExecuteResult, unknown];
629
+ private async _insertCreates(schema: ModelSchema, creates: PreparedCreate[]): Promise<void> {
630
+ if (creates.length === 1) return this._insertCreatesOneByOne(schema, creates);
470
631
 
471
- // Re-key the record in the store if MySQL generated the ID
472
- if (isPendingId && result.insertId) {
473
- const pendingId = record.id;
474
- const realId = result.insertId;
475
- const modelStore = this.deps.store.get(modelName);
476
- if (!modelStore) throw new Error(`Model "${modelName}" not found in store during ID re-key`);
632
+ let firstInsertId: number | undefined;
477
633
 
478
- modelStore.delete(pendingId as number | string);
479
- record.__data.id = realId;
480
- record.id = realId;
481
- modelStore.set(realId as number | string, record);
634
+ try {
635
+ const { sql, values } = this.deps.buildInsertMany(schema.table, creates.map(({ row }) => row));
636
+ const [result] = await this.requirePool().execute(sql, values) as [ExecuteResult, unknown];
637
+ firstInsertId = result.insertId;
638
+ } catch {
639
+ return this._insertCreatesOneByOne(schema, creates);
640
+ }
482
641
 
483
- // Update the response data with the real ID
484
- if (response?.data) {
485
- response.data.id = realId;
486
- }
642
+ creates.forEach((create, index) => {
643
+ if (firstInsertId) this._adoptGeneratedId(create, firstInsertId + index);
644
+ create.write.resolve();
645
+ });
646
+ }
487
647
 
488
- delete record.__data.__pendingSqlId;
648
+ private async _insertCreatesOneByOne(schema: ModelSchema, creates: PreparedCreate[]): Promise<void> {
649
+ for (const create of creates) {
650
+ try {
651
+ const { sql, values } = this.deps.buildInsert(schema.table, create.row);
652
+ const [result] = await this.requirePool().execute(sql, values) as [ExecuteResult, unknown];
653
+ if (result.insertId) this._adoptGeneratedId(create, result.insertId);
654
+ create.write.resolve();
655
+ } catch (error) {
656
+ create.write.reject(error);
657
+ }
489
658
  }
490
659
  }
491
660
 
661
+ private _adoptGeneratedId({ modelName, record, isPendingId, write }: PreparedCreate, realId: number): void {
662
+ if (!isPendingId) return;
663
+
664
+ const pendingId = record.id;
665
+ const modelStore = this.deps.store.get(modelName);
666
+ if (!modelStore) throw new Error(`Model "${modelName}" not found in store during ID re-key`);
667
+
668
+ modelStore.delete(pendingId as number | string);
669
+ record.__data.id = realId;
670
+ record.id = realId;
671
+ modelStore.set(realId, record);
672
+
673
+ if (write.response?.data) write.response.data.id = realId;
674
+
675
+ delete record.__data.__pendingSqlId;
676
+ }
677
+
492
678
  private async _persistUpdate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
493
679
  const schemas = this.deps.introspectModels();
494
680
  const schema = schemas[modelName];
@@ -27,6 +27,27 @@ export function buildInsert(table: string, data: Record<string, unknown>): Query
27
27
  return { sql, values };
28
28
  }
29
29
 
30
+ export function buildInsertMany(table: string, rows: Record<string, unknown>[]): QueryResult {
31
+ validateIdentifier(table, 'table name');
32
+
33
+ const columns = [...new Set(rows.flatMap(row => Object.keys(row)))];
34
+ columns.forEach(column => validateIdentifier(column, 'column name'));
35
+
36
+ const values: unknown[] = [];
37
+ const tuples = rows.map(row => {
38
+ const cells = columns.map(column => {
39
+ if (!Object.hasOwn(row, column)) return 'DEFAULT';
40
+ values.push(row[column]);
41
+ return '?';
42
+ });
43
+ return `(${cells.join(', ')})`;
44
+ });
45
+
46
+ const sql = `INSERT INTO \`${table}\` (${columns.map(column => `\`${column}\``).join(', ')}) VALUES ${tuples.join(', ')}`;
47
+
48
+ return { sql, values };
49
+ }
50
+
30
51
  export function buildUpdate(table: string, id: unknown, data: Record<string, unknown>): QueryResult {
31
52
  validateIdentifier(table, 'table name');
32
53
 
@@ -134,6 +134,7 @@ export default class PostgresDB {
134
134
  pool!: Pool | null;
135
135
  pgConfig!: Record<string, unknown>;
136
136
 
137
+ readonly persistResolvesAfterWrite = true;
137
138
  private _queuedWrites: QueuedWrite[] = [];
138
139
  private _inFlightWrites = new Set<QueuedWrite>();
139
140
  private _drainingWrites: Promise<void> | null = null;