@stonyx/orm 0.3.2-beta.33 → 0.3.2-beta.330
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 +377 -30
- package/config/environment.js +111 -12
- package/dist/column-conversion.d.ts +34 -0
- package/dist/column-conversion.js +34 -0
- package/dist/commands.js +34 -0
- package/dist/dynamodb/connection.d.ts +31 -0
- package/dist/dynamodb/connection.js +28 -0
- package/dist/dynamodb/dynamodb-db.d.ts +142 -0
- package/dist/dynamodb/dynamodb-db.js +596 -0
- package/dist/dynamodb/operation-builder.d.ts +76 -0
- package/dist/dynamodb/operation-builder.js +116 -0
- package/dist/dynamodb/type-map.d.ts +31 -0
- package/dist/dynamodb/type-map.js +48 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/main.d.ts +1 -0
- package/dist/main.js +29 -1
- package/dist/manage-record.d.ts +4 -0
- package/dist/manage-record.js +95 -34
- package/dist/mysql/connection.d.ts +3 -0
- package/dist/mysql/mysql-db.d.ts +32 -2
- package/dist/mysql/mysql-db.js +239 -45
- package/dist/mysql/query-builder.d.ts +1 -0
- package/dist/mysql/query-builder.js +17 -0
- package/dist/normalize-record-id.d.ts +67 -0
- package/dist/normalize-record-id.js +88 -0
- package/dist/orm-request.d.ts +17 -1
- package/dist/orm-request.js +77 -19
- package/dist/postgres/column-rules.d.ts +33 -0
- package/dist/postgres/column-rules.js +34 -0
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +36 -2
- package/dist/postgres/postgres-db.js +244 -45
- package/dist/postgres/query-builder.d.ts +1 -0
- package/dist/postgres/query-builder.js +17 -0
- package/dist/record.js +7 -5
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/store.d.ts +13 -1
- package/dist/store.js +65 -6
- package/dist/timescale/query-builder.d.ts +1 -1
- package/dist/timescale/query-builder.js +1 -2
- package/dist/types/orm-types.d.ts +11 -0
- package/package.json +19 -7
- package/src/column-conversion.ts +53 -0
- package/src/commands.ts +43 -0
- package/src/dynamodb/connection.ts +50 -0
- package/src/dynamodb/dynamodb-db.ts +811 -0
- package/src/dynamodb/operation-builder.ts +202 -0
- package/src/dynamodb/type-map.ts +54 -0
- package/src/index.ts +4 -2
- package/src/main.ts +30 -1
- package/src/manage-record.ts +112 -33
- package/src/mysql/connection.ts +3 -0
- package/src/mysql/mysql-db.ts +264 -46
- package/src/mysql/query-builder.ts +21 -0
- package/src/normalize-record-id.ts +88 -0
- package/src/orm-request.ts +109 -18
- package/src/postgres/column-rules.ts +37 -0
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +277 -49
- package/src/postgres/query-builder.ts +21 -0
- package/src/record.ts +8 -5
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/store.ts +68 -6
- package/src/timescale/query-builder.ts +1 -2
- package/src/types/orm-types.ts +12 -0
- package/src/types/stonyx.d.ts +7 -1
- package/config/environment.ts +0 -91
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,6 +123,13 @@ export default class MysqlDB {
|
|
|
84
123
|
pool!: Pool | null;
|
|
85
124
|
mysqlConfig!: MysqlConfig;
|
|
86
125
|
|
|
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;
|
|
132
|
+
|
|
87
133
|
constructor(deps: Partial<MysqlDBDeps> = {}) {
|
|
88
134
|
if (MysqlDB.instance) return MysqlDB.instance;
|
|
89
135
|
MysqlDB.instance = this;
|
|
@@ -104,6 +150,20 @@ export default class MysqlDB {
|
|
|
104
150
|
this.pool = await this.deps.getPool(this.mysqlConfig);
|
|
105
151
|
await this.deps.ensureMigrationsTable(this.pool, this.mysqlConfig.migrationsTable);
|
|
106
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;
|
|
107
167
|
}
|
|
108
168
|
|
|
109
169
|
async startup(): Promise<void> {
|
|
@@ -118,7 +178,15 @@ export default class MysqlDB {
|
|
|
118
178
|
if (pending.length > 0) {
|
|
119
179
|
this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
|
|
120
180
|
|
|
121
|
-
|
|
181
|
+
let shouldApply: boolean;
|
|
182
|
+
if (this.mysqlConfig.autoMigrate === true) {
|
|
183
|
+
shouldApply = true;
|
|
184
|
+
} else if (this.mysqlConfig.autoMigrate === false) {
|
|
185
|
+
shouldApply = false;
|
|
186
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
|
|
187
|
+
} else {
|
|
188
|
+
shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
|
|
189
|
+
}
|
|
122
190
|
|
|
123
191
|
if (shouldApply) {
|
|
124
192
|
for (const filename of pending) {
|
|
@@ -139,9 +207,17 @@ export default class MysqlDB {
|
|
|
139
207
|
const modelCount = Object.keys(schemas).length;
|
|
140
208
|
|
|
141
209
|
if (modelCount > 0) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
210
|
+
let shouldGenerate: boolean;
|
|
211
|
+
if (this.mysqlConfig.autoMigrate === true) {
|
|
212
|
+
shouldGenerate = true;
|
|
213
|
+
} else if (this.mysqlConfig.autoMigrate === false) {
|
|
214
|
+
shouldGenerate = false;
|
|
215
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
|
|
216
|
+
} else {
|
|
217
|
+
shouldGenerate = await this.deps.confirm(
|
|
218
|
+
`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
145
221
|
|
|
146
222
|
if (shouldGenerate) {
|
|
147
223
|
const { generateMigration } = await import('./migration-generator.js');
|
|
@@ -175,10 +251,36 @@ export default class MysqlDB {
|
|
|
175
251
|
}
|
|
176
252
|
|
|
177
253
|
async shutdown(): Promise<void> {
|
|
254
|
+
this._acceptingWrites = false;
|
|
255
|
+
while (this._drainingWrites) await this._drainingWrites;
|
|
256
|
+
this._unregisterExitReport();
|
|
178
257
|
await this.deps.closePool();
|
|
179
258
|
this.pool = null;
|
|
180
259
|
}
|
|
181
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
|
+
|
|
182
284
|
async save(): Promise<void> {
|
|
183
285
|
// No-op: MySQL persists data immediately via persist()
|
|
184
286
|
}
|
|
@@ -394,69 +496,185 @@ export default class MysqlDB {
|
|
|
394
496
|
}
|
|
395
497
|
|
|
396
498
|
async persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
|
|
397
|
-
// Views are read-only — no-op for all write operations
|
|
398
499
|
const Orm = (await import('@stonyx/orm')).default;
|
|
399
500
|
if ((Orm as unknown as { instance?: { isView?: (name: string) => boolean } }).instance?.isView?.(modelName)) return;
|
|
400
501
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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));
|
|
537
|
+
}
|
|
408
538
|
}
|
|
409
539
|
}
|
|
410
540
|
|
|
411
|
-
private
|
|
412
|
-
const
|
|
413
|
-
|
|
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
|
+
}
|
|
414
553
|
|
|
415
|
-
|
|
554
|
+
return this._queuedWrites.splice(0, groupSize);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private async _executeWriteGroup(writes: QueuedWrite[]): Promise<void> {
|
|
558
|
+
const [first] = writes;
|
|
559
|
+
if (first.operation === 'create') return this._persistCreates(first.modelName, writes);
|
|
416
560
|
|
|
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
|
+
}
|
|
588
|
+
|
|
589
|
+
private _prepareCreate(modelName: string, schema: ModelSchema, write: QueuedWrite): PreparedCreate | null {
|
|
590
|
+
const { context, response } = write;
|
|
417
591
|
const recordId = response?.data?.id;
|
|
418
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;
|
|
419
593
|
|
|
420
|
-
if (!record) return;
|
|
421
|
-
|
|
422
|
-
const insertData = this._recordToRow(record, schema);
|
|
594
|
+
if (!record) return null;
|
|
423
595
|
|
|
424
|
-
|
|
425
|
-
// Check context.rawData (not record.__data) because __pendingSqlId is not a model
|
|
426
|
-
// attribute and gets lost during serialization.
|
|
596
|
+
const row = this._recordToRow(record, schema);
|
|
427
597
|
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
428
598
|
|
|
429
|
-
if (isPendingId)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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);
|
|
433
623
|
}
|
|
434
624
|
|
|
435
|
-
|
|
625
|
+
if (chunk.length > 0) chunks.push(chunk);
|
|
626
|
+
return chunks;
|
|
627
|
+
}
|
|
436
628
|
|
|
437
|
-
|
|
629
|
+
private async _insertCreates(schema: ModelSchema, creates: PreparedCreate[]): Promise<void> {
|
|
630
|
+
if (creates.length === 1) return this._insertCreatesOneByOne(schema, creates);
|
|
438
631
|
|
|
439
|
-
|
|
440
|
-
if (isPendingId && result.insertId) {
|
|
441
|
-
const pendingId = record.id;
|
|
442
|
-
const realId = result.insertId;
|
|
443
|
-
const modelStore = this.deps.store.get(modelName);
|
|
444
|
-
if (!modelStore) throw new Error(`Model "${modelName}" not found in store during ID re-key`);
|
|
632
|
+
let firstInsertId: number | undefined;
|
|
445
633
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
+
}
|
|
450
641
|
|
|
451
|
-
|
|
452
|
-
if (
|
|
453
|
-
|
|
454
|
-
|
|
642
|
+
creates.forEach((create, index) => {
|
|
643
|
+
if (firstInsertId) this._adoptGeneratedId(create, firstInsertId + index);
|
|
644
|
+
create.write.resolve();
|
|
645
|
+
});
|
|
646
|
+
}
|
|
455
647
|
|
|
456
|
-
|
|
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
|
+
}
|
|
457
658
|
}
|
|
458
659
|
}
|
|
459
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
|
+
|
|
460
678
|
private async _persistUpdate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
|
|
461
679
|
const schemas = this.deps.introspectModels();
|
|
462
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
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 Stone Costa
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the 'License');
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an 'AS IS' BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The ONE place a URL id is turned into the value a record is resolved by —
|
|
19
|
+
* abofs/stonyx-orm#270.
|
|
20
|
+
*
|
|
21
|
+
* Before this existed this coercion was written out seven times: twice in
|
|
22
|
+
* README.md, once in docs/usage-patterns.md, and four times inside the
|
|
23
|
+
* framework. Seven copies, not seven identical copies — the three persistence
|
|
24
|
+
* ones (abofs/stonyx-orm#282) omit the `if (!id) return ''` guard below, which
|
|
25
|
+
* is the point: nothing held them together, so they had already drifted.
|
|
26
|
+
* The framework's copy — `getId()`, module-private in
|
|
27
|
+
* orm-request.ts, unreachable through the package `exports` map — was the one
|
|
28
|
+
* that decided which record a request addressed, and a consumer's `access()`
|
|
29
|
+
* predicate had no way to obtain it. So the framework resolved the record by
|
|
30
|
+
* one value and asked the consumer to authorize on a different one.
|
|
31
|
+
*
|
|
32
|
+
* Measured consequence at the time of filing: with the documented predicate
|
|
33
|
+
* applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
|
|
34
|
+
* `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
|
|
35
|
+
* `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
|
|
36
|
+
* those onto `7` while `'007' === '7'` is false.
|
|
37
|
+
*
|
|
38
|
+
* Two things follow from this being a single exported function, and both are
|
|
39
|
+
* the point:
|
|
40
|
+
*
|
|
41
|
+
* 1. A permissive change here is HARMLESS, because both sides move together.
|
|
42
|
+
* Lowercasing string ids used to disclose and destroy `owner:angela` with
|
|
43
|
+
* the whole suite green; with one implementation the predicate simply sees
|
|
44
|
+
* the same lowercased value and still refuses.
|
|
45
|
+
* 2. A divergence — a second, private normaliser at the resolution site — is
|
|
46
|
+
* what is now dangerous, and that is what the tests pin, by observing the
|
|
47
|
+
* key the resolution path actually used.
|
|
48
|
+
*
|
|
49
|
+
* SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
|
|
50
|
+
* Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
|
|
51
|
+
* question, and it is a behaviour change on the record-resolution path for
|
|
52
|
+
* every consumer rather than an authorization fix — it belongs in its own
|
|
53
|
+
* issue with its own compatibility argument (issue body scope item 4). #270
|
|
54
|
+
* preserves today's semantics exactly and makes both sides agree on them.
|
|
55
|
+
*
|
|
56
|
+
* Two details are load-bearing and are pinned by
|
|
57
|
+
* test/unit/normalize-record-id-test.ts:
|
|
58
|
+
*
|
|
59
|
+
* - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
|
|
60
|
+
* `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
|
|
61
|
+
* silently changes which record every hex-spelled URL addresses.
|
|
62
|
+
* - The coercion applies only when the id LOOKS numeric, so a model with
|
|
63
|
+
* string ids is passed through untouched, case included.
|
|
64
|
+
*
|
|
65
|
+
* Must stay synchronous: `auth()` is invoked without `await` by
|
|
66
|
+
* @stonyx/rest-server, so a promise here would be handed to `access()` as the
|
|
67
|
+
* record id.
|
|
68
|
+
*
|
|
69
|
+
* FALSY AND `NaN` RETURNS ARE LOAD-BEARING ELSEWHERE — abofs/stonyx-orm#287.
|
|
70
|
+
* `''` is returned for any falsy id because `store.get(key, undefined)` returns
|
|
71
|
+
* the whole model Map rather than a record (abofs/stonyx-orm#167). But `''` is
|
|
72
|
+
* not the only falsy return: `'0'`, `'00'`, `'-0'` and `'0x0'` all normalise to
|
|
73
|
+
* `0`, and `' '`, `'\t'`, `'\n'`, `'\u00a0'` all normalise to `NaN` (their
|
|
74
|
+
* `Number()` is `0`, so the `isNaN` guard does not fire and `parseInt` runs).
|
|
75
|
+
* `store.remove(key, id)` branches on truthiness, so those spellings reach a
|
|
76
|
+
* fall-through this function does not own. Tracked as #287; every row is pinned
|
|
77
|
+
* in test/unit/normalize-record-id-test.ts so a cleanup here cannot move the
|
|
78
|
+
* boundary #287 is measured against.
|
|
79
|
+
*
|
|
80
|
+
* @param id the raw, already-URL-decoded id text from `request.params.id`
|
|
81
|
+
* @returns the value the ORM resolves the record by
|
|
82
|
+
*/
|
|
83
|
+
export default function normalizeRecordId(id?: string | null): string | number {
|
|
84
|
+
if (!id) return '';
|
|
85
|
+
if (isNaN(id as unknown as number)) return id;
|
|
86
|
+
|
|
87
|
+
return parseInt(id);
|
|
88
|
+
}
|