@stonyx/orm 0.3.2-beta.327 → 0.3.2-beta.329
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/README.md +2 -0
- package/config/environment.js +4 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/main.d.ts +1 -0
- package/dist/manage-record.d.ts +4 -0
- package/dist/manage-record.js +72 -42
- package/dist/mysql/connection.d.ts +2 -0
- package/dist/mysql/mysql-db.d.ts +32 -10
- package/dist/mysql/mysql-db.js +218 -58
- package/dist/mysql/query-builder.d.ts +1 -0
- package/dist/mysql/query-builder.js +17 -0
- package/dist/postgres/postgres-db.d.ts +1 -0
- package/dist/postgres/postgres-db.js +1 -0
- package/package.json +1 -1
- package/src/index.ts +2 -2
- package/src/main.ts +1 -0
- package/src/manage-record.ts +89 -42
- package/src/mysql/connection.ts +2 -0
- package/src/mysql/mysql-db.ts +242 -56
- package/src/mysql/query-builder.ts +21 -0
- package/src/postgres/postgres-db.ts +1 -0
|
@@ -4,6 +4,7 @@ interface QueryResult {
|
|
|
4
4
|
}
|
|
5
5
|
export declare function validateIdentifier(name: string, context?: string): string;
|
|
6
6
|
export declare function buildInsert(table: string, data: Record<string, unknown>): QueryResult;
|
|
7
|
+
export declare function buildInsertMany(table: string, rows: Record<string, unknown>[]): QueryResult;
|
|
7
8
|
export declare function buildUpdate(table: string, id: unknown, data: Record<string, unknown>): QueryResult;
|
|
8
9
|
export declare function buildDelete(table: string, id: unknown): QueryResult;
|
|
9
10
|
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(', ')})`;
|
|
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 '?';
|
|
28
|
+
});
|
|
29
|
+
return `(${cells.join(', ')})`;
|
|
30
|
+
});
|
|
31
|
+
const sql = `INSERT INTO \`${table}\` (${columns.map(column => `\`${column}\``).join(', ')}) VALUES ${tuples.join(', ')}`;
|
|
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);
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -21,14 +21,14 @@ import Serializer from './serializer.js';
|
|
|
21
21
|
import attr from './attr.js';
|
|
22
22
|
import belongsTo from './belongs-to.js';
|
|
23
23
|
import hasMany from './has-many.js';
|
|
24
|
-
import { createRecord, updateRecord } from './manage-record.js';
|
|
24
|
+
import { createRecord, updateRecord, createRecordAndPersist, updateRecordAndPersist } from './manage-record.js';
|
|
25
25
|
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
29
|
export type { PersistErrorDetail } from './main.js';
|
|
30
30
|
export { Model, View, Serializer }; // base classes
|
|
31
|
-
export { attr, belongsTo, hasMany, createRecord, updateRecord };
|
|
31
|
+
export { attr, belongsTo, hasMany, createRecord, updateRecord, createRecordAndPersist, updateRecordAndPersist };
|
|
32
32
|
export { count, avg, sum, min, max }; // aggregate helpers
|
|
33
33
|
export { default as normalizeRecordId } from './normalize-record-id.js'; // canonical URL-id -> record-id resolution (#270)
|
|
34
34
|
export type { OrmRequest$ as OrmAccessRequest } from './orm-request.js'; // the request access() is handed, incl. recordId (#270)
|
package/src/main.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface SqlDb {
|
|
|
34
34
|
startup(): Promise<void>;
|
|
35
35
|
shutdown(): Promise<void>;
|
|
36
36
|
persist(operation: string, model: string, context: unknown, response: unknown): Promise<void>;
|
|
37
|
+
persistResolvesAfterWrite?: boolean;
|
|
37
38
|
findRecord(modelName: string, id: unknown): Promise<unknown>;
|
|
38
39
|
findAll(modelName: string, conditions?: Record<string, unknown>): Promise<unknown[]>;
|
|
39
40
|
}
|
package/src/manage-record.ts
CHANGED
|
@@ -28,7 +28,73 @@ const defaultOptions: CreateRecordOptions = {
|
|
|
28
28
|
|
|
29
29
|
let pendingIdCounter = 0;
|
|
30
30
|
|
|
31
|
+
interface RecordWrite<Result> {
|
|
32
|
+
result: Result;
|
|
33
|
+
written: Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type AwaitedOperation = 'create' | 'update';
|
|
37
|
+
|
|
38
|
+
const writeNotRequested = Promise.resolve();
|
|
39
|
+
|
|
40
|
+
function ignoreReportedFailure(): void {}
|
|
41
|
+
|
|
42
|
+
function persistRequested(options: CreateRecordOptions, userOptions: CreateRecordOptions): boolean {
|
|
43
|
+
return !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function reportPersistFailure(operation: AwaitedOperation, modelName: string, recordId: unknown, failure: unknown): Error {
|
|
47
|
+
const error = failure instanceof Error ? failure : new Error(String(failure));
|
|
48
|
+
Orm.instance.emitPersistError({ operation, modelName, recordId, error });
|
|
49
|
+
return error;
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
export function createRecord(modelName: string, rawData: { [key: string]: unknown } = {}, userOptions: CreateRecordOptions = {}): OrmRecord {
|
|
53
|
+
const { result: record, written } = createRecordWrite(modelName, rawData, userOptions);
|
|
54
|
+
written.catch(ignoreReportedFailure);
|
|
55
|
+
return record;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function updateRecord(record: OrmRecord, rawData: unknown, userOptions: CreateRecordOptions = {}): void {
|
|
59
|
+
updateRecordWrite(record, rawData, userOptions).written.catch(ignoreReportedFailure);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function createRecordAndPersist(modelName: string, rawData: { [key: string]: unknown } = {}, userOptions: CreateRecordOptions = {}): Promise<OrmRecord> {
|
|
63
|
+
assertWriteCanBeAwaited();
|
|
64
|
+
const { result: record, written } = createRecordWrite(modelName, rawData, userOptions);
|
|
65
|
+
await written;
|
|
66
|
+
await saveJsonDatabase('create', modelName, record.id, { ...defaultOptions, ...userOptions }, userOptions);
|
|
67
|
+
return record;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function updateRecordAndPersist(record: OrmRecord, rawData: unknown, userOptions: CreateRecordOptions = {}): Promise<void> {
|
|
71
|
+
assertWriteCanBeAwaited();
|
|
72
|
+
await updateRecordWrite(record, rawData, userOptions).written;
|
|
73
|
+
await saveJsonDatabase('update', record?.__model?.__name ?? '', record.id, { ...defaultOptions, ...userOptions }, userOptions);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertWriteCanBeAwaited(): void {
|
|
77
|
+
const sqlDb = Orm.instance?.sqlDb as { persistResolvesAfterWrite?: boolean } | undefined;
|
|
78
|
+
if (sqlDb && sqlDb.persistResolvesAfterWrite !== true) {
|
|
79
|
+
throw new Error('The configured database adapter cannot confirm a write has landed, so createRecordAndPersist/updateRecordAndPersist are unavailable for it (DynamoDB: abofs/stonyx-orm#299)');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function saveJsonDatabase(operation: AwaitedOperation, modelName: string, recordId: unknown, options: CreateRecordOptions, userOptions: CreateRecordOptions): Promise<void> {
|
|
84
|
+
const orm = Orm.instance;
|
|
85
|
+
if (orm.sqlDb || !persistRequested(options, userOptions)) return;
|
|
86
|
+
|
|
87
|
+
const jsonDatabase = orm.db as { save?: () => Promise<void> } | undefined;
|
|
88
|
+
if (!jsonDatabase?.save) return;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await jsonDatabase.save();
|
|
92
|
+
} catch (failure) {
|
|
93
|
+
throw reportPersistFailure(operation, modelName, recordId, failure);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createRecordWrite(modelName: string, rawData: { [key: string]: unknown }, userOptions: CreateRecordOptions): RecordWrite<OrmRecord> {
|
|
32
98
|
const orm = Orm.instance;
|
|
33
99
|
const { initialized } = Orm;
|
|
34
100
|
const options = { ...defaultOptions, ...userOptions };
|
|
@@ -50,9 +116,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
50
116
|
const existingRecord = modelStore.get(rawData.id as number | string);
|
|
51
117
|
|
|
52
118
|
if (existingRecord instanceof OrmRecord) {
|
|
53
|
-
|
|
54
|
-
updateRecord(existingRecord, rawData, { ...options, update: true });
|
|
55
|
-
return existingRecord;
|
|
119
|
+
return { result: existingRecord, written: updateRecordWrite(existingRecord, rawData, { ...options, update: true }).written };
|
|
56
120
|
}
|
|
57
121
|
|
|
58
122
|
const recordClasses = orm.getRecordClasses(modelName);
|
|
@@ -136,37 +200,26 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
136
200
|
pendingBelongsTo.length = 0;
|
|
137
201
|
}
|
|
138
202
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
error: err instanceof Error ? err : new Error(String(err)),
|
|
153
|
-
});
|
|
154
|
-
})
|
|
155
|
-
.finally(() => {
|
|
156
|
-
// Evict non-memory records after persist to prevent unbounded heap growth (stonyx#81)
|
|
157
|
-
if (store._memoryResolver && !store._memoryResolver(modelName)) {
|
|
158
|
-
store.evictRecord(modelName, record.id, registryId);
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
-
}
|
|
203
|
+
if (!orm?.sqlDb || !persistRequested(options, userOptions)) return { result: record, written: writeNotRequested };
|
|
204
|
+
|
|
205
|
+
const registryIdBeforePersist = record.id;
|
|
206
|
+
const response = { data: { id: record.id } };
|
|
207
|
+
const written = orm.sqlDb.persist('create', modelName, { rawData }, response)
|
|
208
|
+
.catch((failure: unknown) => {
|
|
209
|
+
throw reportPersistFailure('create', modelName, record.id, failure);
|
|
210
|
+
})
|
|
211
|
+
.finally(() => {
|
|
212
|
+
if (store._memoryResolver && !store._memoryResolver(modelName)) {
|
|
213
|
+
store.evictRecord(modelName, record.id, registryIdBeforePersist);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
162
216
|
|
|
163
|
-
return record;
|
|
217
|
+
return { result: record, written };
|
|
164
218
|
}
|
|
165
219
|
|
|
166
|
-
|
|
220
|
+
function updateRecordWrite(record: OrmRecord, rawData: unknown, userOptions: CreateRecordOptions): RecordWrite<void> {
|
|
167
221
|
if (!rawData) throw new Error('rawData must be passed in to updateRecord call');
|
|
168
222
|
|
|
169
|
-
// Guard: read-only views cannot be updated
|
|
170
223
|
const modelName = record?.__model?.__name;
|
|
171
224
|
if (modelName && Orm.instance?.isView?.(modelName)) {
|
|
172
225
|
throw new Error(`Cannot update records for read-only view '${modelName}'`);
|
|
@@ -174,24 +227,18 @@ export function updateRecord(record: OrmRecord, rawData: unknown, userOptions: C
|
|
|
174
227
|
|
|
175
228
|
const options = { ...defaultOptions, ...userOptions, update: true };
|
|
176
229
|
|
|
177
|
-
// Capture old state before update for SQL diff
|
|
178
230
|
const oldState = record.__data ? JSON.parse(JSON.stringify(record.__data)) : {};
|
|
179
231
|
|
|
180
232
|
record.serialize(rawData, options);
|
|
181
233
|
|
|
182
|
-
// Auto-persist to SQL — skip for DB loads (isDbRecord) and relationship resolution (_relationshipKey)
|
|
183
234
|
const orm = Orm.instance;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
error: err instanceof Error ? err : new Error(String(err)),
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
}
|
|
235
|
+
if (!orm?.sqlDb || !modelName || !persistRequested(options, userOptions)) return { result: undefined, written: writeNotRequested };
|
|
236
|
+
|
|
237
|
+
const written = orm.sqlDb.persist('update', modelName, { record, oldState }, {}).catch((failure: unknown) => {
|
|
238
|
+
throw reportPersistFailure('update', modelName, record.id, failure);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return { result: undefined, written };
|
|
195
242
|
}
|
|
196
243
|
|
|
197
244
|
/**
|
package/src/mysql/connection.ts
CHANGED
package/src/mysql/mysql-db.ts
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
|
444
|
-
const
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
625
|
+
if (chunk.length > 0) chunks.push(chunk);
|
|
626
|
+
return chunks;
|
|
627
|
+
}
|
|
468
628
|
|
|
469
|
-
|
|
629
|
+
private async _insertCreates(schema: ModelSchema, creates: PreparedCreate[]): Promise<void> {
|
|
630
|
+
if (creates.length === 1) return this._insertCreatesOneByOne(schema, creates);
|
|
470
631
|
|
|
471
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
484
|
-
if (
|
|
485
|
-
|
|
486
|
-
|
|
642
|
+
creates.forEach((create, index) => {
|
|
643
|
+
if (firstInsertId) this._adoptGeneratedId(create, firstInsertId + index);
|
|
644
|
+
create.write.resolve();
|
|
645
|
+
});
|
|
646
|
+
}
|
|
487
647
|
|
|
488
|
-
|
|
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];
|