@stonyx/orm 0.3.2-beta.326 → 0.3.2-beta.328

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.
@@ -2,7 +2,7 @@ import { getPool, closePool } from './connection.js';
2
2
  import { ensureMigrationsTable, getAppliedMigrations, getMigrationFiles, applyMigration, parseMigrationFile } from './migration-runner.js';
3
3
  import { introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot } from './schema-introspector.js';
4
4
  import { loadLatestSnapshot, detectSchemaDrift } from './migration-generator.js';
5
- import { buildInsert, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch } from './query-builder.js';
5
+ import { buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch } from './query-builder.js';
6
6
  import { store } from '@stonyx/orm';
7
7
  import { createRecord } from '../manage-record.js';
8
8
  import { confirm } from '@stonyx/utils/prompt';
@@ -14,13 +14,28 @@ import { isDbError } from '../utils.js';
14
14
  import config from 'stonyx/config';
15
15
  import log from 'stonyx/log';
16
16
  import path from 'path';
17
+ const POSTGRES_MAX_BIND_PARAMETERS = 65535;
18
+ const DEFAULT_WRITE_BATCH_SIZE = 500;
19
+ const DEFAULT_WRITE_QUEUE_LIMIT = 10000;
20
+ export class WriteQueueFullError extends Error {
21
+ constructor(limit) {
22
+ super(`PostgresDB write queue is full (${limit} queued writes, writeQueueLimit); the write was rejected`);
23
+ this.name = 'WriteQueueFullError';
24
+ }
25
+ }
26
+ export class WriteQueueClosedError extends Error {
27
+ constructor() {
28
+ super('PostgresDB is shutting down; the write was rejected');
29
+ this.name = 'WriteQueueClosedError';
30
+ }
31
+ }
17
32
  const defaultDeps = {
18
33
  getPool, closePool, ensureMigrationsTable, getAppliedMigrations,
19
34
  getMigrationFiles, applyMigration, parseMigrationFile,
20
35
  introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
21
36
  loadLatestSnapshot, detectSchemaDrift,
22
- buildInsert, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch,
23
- createRecord, store, confirm, readFile, getPluralName, config, log, path,
37
+ buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect, buildVectorSearch, buildHybridSearch,
38
+ createRecord, store, confirm, readFile, getPluralName, config, log, path, process,
24
39
  convertRow, postgresJsonRule
25
40
  };
26
41
  export default class PostgresDB {
@@ -33,14 +48,11 @@ export default class PostgresDB {
33
48
  deps;
34
49
  pool;
35
50
  pgConfig;
36
- /**
37
- * Promise-chain mutex for write serialization (#156).
38
- * All persist() calls chain through this single queue so concurrent
39
- * fire-and-forget writes never produce parallel transactions
40
- * on FK-linked rows (which cause deadlocks).
41
- * Reads are NOT affected — only persist() serializes.
42
- */
43
- _writeQueue = Promise.resolve();
51
+ _queuedWrites = [];
52
+ _inFlightWrites = new Set();
53
+ _drainingWrites = null;
54
+ _acceptingWrites = true;
55
+ _exitReport = null;
44
56
  constructor(deps = {}) {
45
57
  const Ctor = this.constructor;
46
58
  if (Ctor.instance)
@@ -59,6 +71,17 @@ export default class PostgresDB {
59
71
  this.pool = await this.deps.getPool(this.pgConfig, this.constructor.extensions);
60
72
  await this.deps.ensureMigrationsTable(this.pool, this.pgConfig.migrationsTable);
61
73
  await this.loadMemoryRecords();
74
+ this._acceptingWrites = true;
75
+ this._registerExitReport();
76
+ }
77
+ get writeBatchSize() {
78
+ return Number(this.pgConfig?.writeBatchSize) || DEFAULT_WRITE_BATCH_SIZE;
79
+ }
80
+ get writeQueueLimit() {
81
+ return Number(this.pgConfig?.writeQueueLimit) || DEFAULT_WRITE_QUEUE_LIMIT;
82
+ }
83
+ get pendingWriteCount() {
84
+ return this._inFlightWrites.size + this._queuedWrites.length;
62
85
  }
63
86
  async startup() {
64
87
  const migrationsPath = this.deps.path.resolve(this.deps.config.rootPath, this.pgConfig.migrationsDir);
@@ -135,9 +158,35 @@ export default class PostgresDB {
135
158
  }
136
159
  }
137
160
  async shutdown() {
161
+ this._acceptingWrites = false;
162
+ while (this._drainingWrites)
163
+ await this._drainingWrites;
164
+ this._unregisterExitReport();
138
165
  await this.deps.closePool();
139
166
  this.pool = null;
140
167
  }
168
+ _registerExitReport() {
169
+ if (this._exitReport)
170
+ return;
171
+ this._exitReport = () => this._reportUnpersistedWrites();
172
+ this.deps.process.once('exit', this._exitReport);
173
+ }
174
+ _unregisterExitReport() {
175
+ if (!this._exitReport)
176
+ return;
177
+ this.deps.process.removeListener('exit', this._exitReport);
178
+ this._exitReport = null;
179
+ }
180
+ _reportUnpersistedWrites() {
181
+ const lostWrites = [...this._inFlightWrites, ...this._queuedWrites];
182
+ if (lostWrites.length === 0)
183
+ return;
184
+ const lostByModel = new Map();
185
+ for (const { modelName } of lostWrites)
186
+ lostByModel.set(modelName, (lostByModel.get(modelName) ?? 0) + 1);
187
+ const breakdown = [...lostByModel].map(([modelName, count]) => `${modelName}: ${count}`).join(', ');
188
+ this.deps.log.error?.(`[PostgresDB] Process exiting with ${lostWrites.length} queued write(s) not persisted; they are lost (${breakdown})`);
189
+ }
141
190
  async save() {
142
191
  // No-op: PostgreSQL persists data immediately via persist()
143
192
  }
@@ -383,62 +432,170 @@ export default class PostgresDB {
383
432
  return rawData;
384
433
  }
385
434
  async persist(operation, modelName, context, response) {
386
- // Views are read-only -- no-op for all write operations
387
435
  const Orm = (await import('@stonyx/orm')).default;
388
436
  if (Orm.instance?.isView?.(modelName))
389
437
  return;
390
- const work = async () => {
391
- switch (operation) {
392
- case 'create':
393
- return this._persistCreate(modelName, context, response);
394
- case 'update':
395
- return this._persistUpdate(modelName, context, response);
396
- case 'delete':
397
- return this._persistDelete(modelName, context);
398
- }
399
- };
400
- // Chain through the write queue — .then(work, work) ensures the queue
401
- // advances even when a previous persist rejects (#156).
402
- this._writeQueue = this._writeQueue.then(work, work);
403
- return this._writeQueue;
404
- }
405
- async _persistCreate(modelName, context, response) {
406
- const schemas = this.deps.introspectModels();
407
- const schema = schemas[modelName];
408
- if (!schema)
438
+ if (!this._acceptingWrites)
439
+ throw new WriteQueueClosedError();
440
+ if (this._queuedWrites.length >= this.writeQueueLimit)
441
+ throw new WriteQueueFullError(this.writeQueueLimit);
442
+ return new Promise((resolve, reject) => {
443
+ const write = {
444
+ operation, modelName, context, response,
445
+ resolve: () => {
446
+ this._inFlightWrites.delete(write);
447
+ resolve();
448
+ },
449
+ reject: (error) => {
450
+ this._inFlightWrites.delete(write);
451
+ reject(error);
452
+ },
453
+ };
454
+ this._queuedWrites.push(write);
455
+ this._drainWritesInBackground();
456
+ });
457
+ }
458
+ _drainWritesInBackground() {
459
+ if (this._drainingWrites)
409
460
  return;
461
+ this._drainingWrites = this._drainQueuedWrites().finally(() => {
462
+ this._drainingWrites = null;
463
+ });
464
+ }
465
+ async _drainQueuedWrites() {
466
+ while (this._queuedWrites.length > 0) {
467
+ const writes = this._takeNextWriteGroup();
468
+ writes.forEach(write => this._inFlightWrites.add(write));
469
+ try {
470
+ await this._executeWriteGroup(writes);
471
+ }
472
+ catch (error) {
473
+ writes.filter(write => this._inFlightWrites.has(write)).forEach(write => write.reject(error));
474
+ }
475
+ }
476
+ }
477
+ _takeNextWriteGroup() {
478
+ const [first] = this._queuedWrites;
479
+ let groupSize = 1;
480
+ if (first.operation === 'create') {
481
+ while (groupSize < this.writeBatchSize
482
+ && groupSize < this._queuedWrites.length
483
+ && this._queuedWrites[groupSize].operation === 'create'
484
+ && this._queuedWrites[groupSize].modelName === first.modelName)
485
+ groupSize++;
486
+ }
487
+ return this._queuedWrites.splice(0, groupSize);
488
+ }
489
+ async _executeWriteGroup(writes) {
490
+ const [first] = writes;
491
+ if (first.operation === 'create')
492
+ return this._persistCreates(first.modelName, writes);
493
+ try {
494
+ if (first.operation === 'update')
495
+ await this._persistUpdate(first.modelName, first.context, first.response);
496
+ if (first.operation === 'delete')
497
+ await this._persistDelete(first.modelName, first.context);
498
+ first.resolve();
499
+ }
500
+ catch (error) {
501
+ first.reject(error);
502
+ }
503
+ }
504
+ async _persistCreates(modelName, writes) {
505
+ const schema = this.deps.introspectModels()[modelName];
506
+ const prepared = [];
507
+ for (const write of writes) {
508
+ try {
509
+ const create = schema ? this._prepareCreate(modelName, schema, write) : null;
510
+ if (create)
511
+ prepared.push(create);
512
+ else
513
+ write.resolve();
514
+ }
515
+ catch (error) {
516
+ write.reject(error);
517
+ }
518
+ }
519
+ for (const chunk of this._chunkWithinBindParameterLimit(prepared)) {
520
+ await this._insertCreates(schema, chunk);
521
+ }
522
+ }
523
+ _prepareCreate(modelName, schema, write) {
524
+ const { context, response } = write;
410
525
  const recordId = response?.data?.id;
411
526
  const storeRef = this.deps.store;
412
527
  const record = recordId != null ? storeRef.get(modelName, isNaN(recordId) ? recordId : parseInt(recordId)) : null;
413
528
  if (!record)
414
- return;
529
+ return null;
415
530
  const insertData = this._recordToRow(record, schema, context.rawData);
416
- // For auto-increment models, remove the pending ID.
417
- // Check context.rawData (not record.__data) because __pendingSqlId is not a model
418
- // attribute and gets lost during serialization.
419
531
  const isPendingId = context.rawData?.__pendingSqlId === true;
420
- if (isPendingId) {
532
+ if (isPendingId)
421
533
  delete insertData.id;
422
- }
423
- // Convert the *complete* payload (attributes + id + FKs) in one place (#292).
424
534
  const row = this.deps.convertRow(insertData, schema.columns, this.deps.postgresJsonRule);
425
- const { sql, values } = this.deps.buildInsert(schema.table, row);
426
- const result = await this.requirePool().query(sql, values);
427
- // Re-key the record in the store if PostgreSQL generated the ID (via RETURNING)
428
- if (isPendingId && result.rows.length > 0) {
429
- const pendingId = record.id;
430
- const realId = result.rows[0].id;
431
- const modelStore = this.deps.store.get(modelName);
432
- modelStore.delete(pendingId);
433
- record.__data.id = realId;
434
- record.id = realId;
435
- modelStore.set(realId, record);
436
- // Update the response data with the real ID
437
- if (response?.data) {
438
- response.data.id = realId;
439
- }
440
- delete record.__data.__pendingSqlId;
535
+ return { write, modelName, record, row, isPendingId };
536
+ }
537
+ _chunkWithinBindParameterLimit(prepared) {
538
+ const chunks = [];
539
+ let chunk = [];
540
+ let chunkColumns = new Set();
541
+ for (const create of prepared) {
542
+ const columnsWithCreate = new Set([...chunkColumns, ...Object.keys(create.row)]);
543
+ if (chunk.length > 0 && (chunk.length + 1) * columnsWithCreate.size > POSTGRES_MAX_BIND_PARAMETERS) {
544
+ chunks.push(chunk);
545
+ chunk = [];
546
+ chunkColumns = new Set(Object.keys(create.row));
547
+ }
548
+ else {
549
+ chunkColumns = columnsWithCreate;
550
+ }
551
+ chunk.push(create);
552
+ }
553
+ if (chunk.length > 0)
554
+ chunks.push(chunk);
555
+ return chunks;
556
+ }
557
+ async _insertCreates(schema, creates) {
558
+ if (creates.length === 1)
559
+ return this._insertCreatesOneByOne(schema, creates);
560
+ let result;
561
+ try {
562
+ const { sql, values } = this.deps.buildInsertMany(schema.table, creates.map(({ row }) => row));
563
+ result = await this.requirePool().query(sql, values);
564
+ }
565
+ catch {
566
+ return this._insertCreatesOneByOne(schema, creates);
441
567
  }
568
+ creates.forEach((create, index) => {
569
+ this._adoptGeneratedId(create, result.rows[index]);
570
+ create.write.resolve();
571
+ });
572
+ }
573
+ async _insertCreatesOneByOne(schema, creates) {
574
+ for (const create of creates) {
575
+ try {
576
+ const { sql, values } = this.deps.buildInsert(schema.table, create.row);
577
+ const result = await this.requirePool().query(sql, values);
578
+ this._adoptGeneratedId(create, result.rows[0]);
579
+ create.write.resolve();
580
+ }
581
+ catch (error) {
582
+ create.write.reject(error);
583
+ }
584
+ }
585
+ }
586
+ _adoptGeneratedId({ modelName, record, isPendingId, write }, returnedRow) {
587
+ if (!isPendingId || !returnedRow)
588
+ return;
589
+ const pendingId = record.id;
590
+ const realId = returnedRow.id;
591
+ const modelStore = this.deps.store.get(modelName);
592
+ modelStore.delete(pendingId);
593
+ record.__data.id = realId;
594
+ record.id = realId;
595
+ modelStore.set(realId, record);
596
+ if (write.response?.data)
597
+ write.response.data.id = realId;
598
+ delete record.__data.__pendingSqlId;
442
599
  }
443
600
  async _persistUpdate(modelName, context, _response) {
444
601
  const schemas = this.deps.introspectModels();
@@ -12,6 +12,7 @@ interface HybridSearchOptions {
12
12
  }
13
13
  export declare function validateIdentifier(name: string, context?: string): string;
14
14
  export declare function buildInsert(table: string, data: Record<string, unknown>): QueryResult;
15
+ export declare function buildInsertMany(table: string, rows: Record<string, unknown>[]): QueryResult;
15
16
  export declare function buildUpdate(table: string, id: unknown, data: Record<string, unknown>): QueryResult;
16
17
  export declare function buildDelete(table: string, id: unknown): QueryResult;
17
18
  export declare function buildSelect(table: string, conditions?: Record<string, unknown>): QueryResult;
@@ -14,6 +14,23 @@ export function buildInsert(table, data) {
14
14
  const sql = `INSERT INTO "${table}" (${keys.map(k => `"${k}"`).join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING "id"`;
15
15
  return { sql, values };
16
16
  }
17
+ export function buildInsertMany(table, rows) {
18
+ validateIdentifier(table, 'table name');
19
+ const columns = [...new Set(rows.flatMap(row => Object.keys(row)))];
20
+ columns.forEach(column => validateIdentifier(column, 'column name'));
21
+ const values = [];
22
+ const tuples = rows.map(row => {
23
+ const cells = columns.map(column => {
24
+ if (!Object.hasOwn(row, column))
25
+ return 'DEFAULT';
26
+ values.push(row[column]);
27
+ return `$${values.length}`;
28
+ });
29
+ return `(${cells.join(', ')})`;
30
+ });
31
+ const sql = `INSERT INTO "${table}" (${columns.map(column => `"${column}"`).join(', ')}) VALUES ${tuples.join(', ')} RETURNING "id"`;
32
+ return { sql, values };
33
+ }
17
34
  export function buildUpdate(table, id, data) {
18
35
  validateIdentifier(table, 'table name');
19
36
  const keys = Object.keys(data);
@@ -1,4 +1,4 @@
1
- export { validateIdentifier, buildInsert, buildUpdate, buildDelete, buildSelect } from '../postgres/query-builder.js';
1
+ export { validateIdentifier, buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect } from '../postgres/query-builder.js';
2
2
  export declare function validateInterval(interval: string, context?: string): string;
3
3
  export declare function validateAggregate(expr: string, context?: string): string;
4
4
  interface QueryResult {
@@ -1,5 +1,4 @@
1
- // Re-export all base PostgreSQL query builders
2
- export { validateIdentifier, buildInsert, buildUpdate, buildDelete, buildSelect } from '../postgres/query-builder.js';
1
+ export { validateIdentifier, buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect } from '../postgres/query-builder.js';
3
2
  import { validateIdentifier } from '../postgres/query-builder.js';
4
3
  const SAFE_INTERVAL = /^\d+\s+(microsecond|millisecond|second|minute|hour|day|week|month|year)s?$/i;
5
4
  export function validateInterval(interval, context = 'interval') {
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-beta.326",
7
+ "version": "0.3.2-beta.328",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -10,6 +10,8 @@ interface MysqlConfig {
10
10
  migrationsTable?: string;
11
11
  migrationsDir?: string;
12
12
  autoMigrate?: boolean;
13
+ writeBatchSize?: number;
14
+ writeQueueLimit?: number;
13
15
  }
14
16
 
15
17
  let pool: Pool | null = null;