@stonyx/orm 0.3.2-alpha.11 → 0.3.2-alpha.111
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 +174 -6
- package/dist/dynamodb/connection.d.ts +7 -4
- package/dist/dynamodb/connection.js +4 -4
- package/dist/dynamodb/dynamodb-db.d.ts +11 -0
- package/dist/dynamodb/dynamodb-db.js +54 -14
- package/dist/dynamodb/operation-builder.js +20 -13
- package/dist/main.js +19 -1
- package/dist/manage-record.js +34 -3
- package/dist/mysql/connection.d.ts +1 -0
- package/dist/mysql/mysql-db.d.ts +8 -0
- package/dist/mysql/mysql-db.js +44 -10
- package/dist/orm-request.d.ts +1 -0
- package/dist/orm-request.js +31 -13
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +8 -0
- package/dist/postgres/postgres-db.js +44 -10
- 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/types/orm-types.d.ts +3 -0
- package/package.json +11 -7
- package/src/dynamodb/connection.ts +9 -8
- package/src/dynamodb/dynamodb-db.ts +56 -13
- package/src/dynamodb/operation-builder.ts +29 -15
- package/src/main.ts +19 -1
- package/src/manage-record.ts +41 -9
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- package/src/orm-request.ts +38 -12
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +44 -12
- 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/types/orm-types.ts +3 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
export interface DynamoDBConfig {
|
|
9
9
|
region?: string;
|
|
10
10
|
endpoint?: string;
|
|
11
|
+
tablePrefix?: string;
|
|
11
12
|
[key: string]: unknown;
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -17,27 +18,27 @@ export type DocumentClient = {
|
|
|
17
18
|
send(command: unknown): Promise<unknown>;
|
|
18
19
|
};
|
|
19
20
|
|
|
20
|
-
export type
|
|
21
|
-
export type
|
|
21
|
+
export type DynamoDBClientConstructor = new (options: unknown) => { config: unknown };
|
|
22
|
+
export type DocumentClientFromFn = { from(client: unknown): DocumentClient };
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Create a DynamoDBDocumentClient from the given config.
|
|
25
26
|
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
26
27
|
*/
|
|
27
28
|
export async function createDocumentClient(dbConfig: DynamoDBConfig): Promise<DocumentClient> {
|
|
28
|
-
const {
|
|
29
|
-
|
|
29
|
+
const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb' as string) as {
|
|
30
|
+
DynamoDBClient: DynamoDBClientConstructor;
|
|
30
31
|
};
|
|
31
|
-
const {
|
|
32
|
-
|
|
32
|
+
const { DynamoDBDocumentClient } = await import('@aws-sdk/lib-dynamodb' as string) as {
|
|
33
|
+
DynamoDBDocumentClient: DocumentClientFromFn;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
const clientOptions: Record<string, unknown> = {};
|
|
36
37
|
if (dbConfig.region) clientOptions.region = dbConfig.region;
|
|
37
38
|
if (dbConfig.endpoint) clientOptions.endpoint = dbConfig.endpoint;
|
|
38
39
|
|
|
39
|
-
const rawClient = new
|
|
40
|
-
return
|
|
40
|
+
const rawClient = new DynamoDBClient(clientOptions);
|
|
41
|
+
return DynamoDBDocumentClient.from(rawClient);
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -50,6 +50,24 @@ function generateUlid(): string {
|
|
|
50
50
|
return id;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Generates a monotonically unique numeric ID for DynamoDB tables with numeric keys.
|
|
55
|
+
* Uses timestamp-based generation with a sub-millisecond counter to ensure uniqueness.
|
|
56
|
+
*/
|
|
57
|
+
let _numericIdCounter = 0;
|
|
58
|
+
let _numericIdLastMs = 0;
|
|
59
|
+
|
|
60
|
+
function generateNumericId(): number {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (now === _numericIdLastMs) {
|
|
63
|
+
_numericIdCounter++;
|
|
64
|
+
} else {
|
|
65
|
+
_numericIdLastMs = now;
|
|
66
|
+
_numericIdCounter = 0;
|
|
67
|
+
}
|
|
68
|
+
return now * 1000 + _numericIdCounter;
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
// ---------------------------------------------------------------------------
|
|
54
72
|
// SDK Command factories (injectable for testing without real AWS SDK)
|
|
55
73
|
// ---------------------------------------------------------------------------
|
|
@@ -199,6 +217,10 @@ export default class DynamoDBDB {
|
|
|
199
217
|
return this.client;
|
|
200
218
|
}
|
|
201
219
|
|
|
220
|
+
private _resolveTableName(modelName: string): string {
|
|
221
|
+
return (this.dbConfig.tablePrefix ?? '') + sanitizeTableName(this.deps.getPluralName(modelName));
|
|
222
|
+
}
|
|
223
|
+
|
|
202
224
|
/** Resolve Orm singleton — falls back to real import in production. */
|
|
203
225
|
private async _getOrm(): Promise<OrmModule> {
|
|
204
226
|
if (this.deps._importOrm) return this.deps._importOrm();
|
|
@@ -235,7 +257,7 @@ export default class DynamoDBDB {
|
|
|
235
257
|
const rawClient = new DynamoDBClient(clientOptions);
|
|
236
258
|
|
|
237
259
|
for (const [modelName, schema] of Object.entries(schemas)) {
|
|
238
|
-
const tableName =
|
|
260
|
+
const tableName = this._resolveTableName(modelName);
|
|
239
261
|
const gsis = this._buildGsiDefinitions(modelName, schema);
|
|
240
262
|
|
|
241
263
|
try {
|
|
@@ -297,6 +319,16 @@ export default class DynamoDBDB {
|
|
|
297
319
|
// SqlDb contract — persist
|
|
298
320
|
// -------------------------------------------------------------------------
|
|
299
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
|
+
*/
|
|
300
332
|
async persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
|
|
301
333
|
const OrmModule = await this._getOrm();
|
|
302
334
|
if (OrmModule.default?.instance?.isView?.(modelName)) return;
|
|
@@ -320,7 +352,7 @@ export default class DynamoDBDB {
|
|
|
320
352
|
const schema = schemas[modelName];
|
|
321
353
|
if (!schema) return undefined;
|
|
322
354
|
|
|
323
|
-
const tableName =
|
|
355
|
+
const tableName = this._resolveTableName(modelName);
|
|
324
356
|
const { GetCommand } = await this.deps.loadDocClientCommands();
|
|
325
357
|
|
|
326
358
|
const params = this.deps.buildGetItem(tableName, { id });
|
|
@@ -354,7 +386,7 @@ export default class DynamoDBDB {
|
|
|
354
386
|
const schema = schemas[modelName];
|
|
355
387
|
if (!schema) return [];
|
|
356
388
|
|
|
357
|
-
const tableName =
|
|
389
|
+
const tableName = this._resolveTableName(modelName);
|
|
358
390
|
|
|
359
391
|
try {
|
|
360
392
|
let items: Record<string, unknown>[];
|
|
@@ -421,7 +453,7 @@ export default class DynamoDBDB {
|
|
|
421
453
|
}
|
|
422
454
|
|
|
423
455
|
const schema = schemas[modelName];
|
|
424
|
-
const tableName =
|
|
456
|
+
const tableName = this._resolveTableName(modelName);
|
|
425
457
|
|
|
426
458
|
try {
|
|
427
459
|
const items = await this._paginatedScan(tableName);
|
|
@@ -457,12 +489,13 @@ export default class DynamoDBDB {
|
|
|
457
489
|
if (!record) return;
|
|
458
490
|
|
|
459
491
|
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
460
|
-
const tableName =
|
|
492
|
+
const tableName = this._resolveTableName(modelName);
|
|
461
493
|
|
|
462
|
-
// For
|
|
494
|
+
// For models with a pending ID, generate a unique replacement ID
|
|
463
495
|
let finalId: unknown = record.id;
|
|
464
496
|
if (isPendingId) {
|
|
465
|
-
|
|
497
|
+
const keyType = this.deps.getDynamoKeyType(schema.idType);
|
|
498
|
+
finalId = keyType === 'N' ? generateNumericId() : generateUlid();
|
|
466
499
|
}
|
|
467
500
|
|
|
468
501
|
const item = this._recordToItem(record, schema, context.rawData);
|
|
@@ -494,7 +527,7 @@ export default class DynamoDBDB {
|
|
|
494
527
|
const record = context.record;
|
|
495
528
|
if (!record) return;
|
|
496
529
|
|
|
497
|
-
const tableName =
|
|
530
|
+
const tableName = this._resolveTableName(modelName);
|
|
498
531
|
const id = record.id;
|
|
499
532
|
const oldState = context.oldState || {};
|
|
500
533
|
const currentData = record.__data;
|
|
@@ -504,7 +537,11 @@ export default class DynamoDBDB {
|
|
|
504
537
|
|
|
505
538
|
for (const col of Object.keys(schema.columns)) {
|
|
506
539
|
if (currentData[col] !== oldState[col]) {
|
|
507
|
-
|
|
540
|
+
const value = currentData[col] ?? null;
|
|
541
|
+
// Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
|
|
542
|
+
changedData[col] = (value instanceof Date)
|
|
543
|
+
? value.toISOString()
|
|
544
|
+
: value;
|
|
508
545
|
}
|
|
509
546
|
}
|
|
510
547
|
|
|
@@ -535,7 +572,7 @@ export default class DynamoDBDB {
|
|
|
535
572
|
const id = context.recordId;
|
|
536
573
|
if (id == null) return;
|
|
537
574
|
|
|
538
|
-
const tableName =
|
|
575
|
+
const tableName = this._resolveTableName(modelName);
|
|
539
576
|
const { DeleteCommand } = await this.deps.loadDocClientCommands();
|
|
540
577
|
const params = this.deps.buildDeleteItem(tableName, { id });
|
|
541
578
|
await this.requireClient().send(new DeleteCommand(params));
|
|
@@ -597,7 +634,7 @@ export default class DynamoDBDB {
|
|
|
597
634
|
const schemas = this.deps.introspectModels();
|
|
598
635
|
|
|
599
636
|
for (const [modelName, schema] of Object.entries(schemas)) {
|
|
600
|
-
const tableName =
|
|
637
|
+
const tableName = this._resolveTableName(modelName);
|
|
601
638
|
const modelGsis = new Map<string, string>();
|
|
602
639
|
|
|
603
640
|
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
@@ -659,7 +696,7 @@ export default class DynamoDBDB {
|
|
|
659
696
|
}
|
|
660
697
|
|
|
661
698
|
private _buildGsiDefinitions(modelName: string, schema: ModelSchema): unknown[] {
|
|
662
|
-
const tableName =
|
|
699
|
+
const tableName = this._resolveTableName(modelName);
|
|
663
700
|
const gsis: unknown[] = [];
|
|
664
701
|
|
|
665
702
|
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
@@ -720,7 +757,13 @@ export default class DynamoDBDB {
|
|
|
720
757
|
if (data.id !== undefined) item.id = data.id;
|
|
721
758
|
|
|
722
759
|
for (const col of Object.keys(schema.columns)) {
|
|
723
|
-
if (data[col] !== undefined)
|
|
760
|
+
if (data[col] !== undefined) {
|
|
761
|
+
const value = data[col];
|
|
762
|
+
// Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
|
|
763
|
+
item[col] = (value instanceof Date)
|
|
764
|
+
? value.toISOString()
|
|
765
|
+
: value;
|
|
766
|
+
}
|
|
724
767
|
}
|
|
725
768
|
|
|
726
769
|
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
@@ -131,21 +131,27 @@ export function buildScan(
|
|
|
131
131
|
if (exclusiveStartKey) params.ExclusiveStartKey = exclusiveStartKey;
|
|
132
132
|
|
|
133
133
|
if (conditions && Object.keys(conditions).length > 0) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
134
|
+
const validEntries = Object.entries(conditions).filter(
|
|
135
|
+
([, val]) => val !== undefined && val !== null,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (validEntries.length > 0) {
|
|
139
|
+
const names: Record<string, string> = {};
|
|
140
|
+
const values: Record<string, unknown> = {};
|
|
141
|
+
const clauses: string[] = [];
|
|
142
|
+
|
|
143
|
+
for (const [attr, val] of validEntries) {
|
|
144
|
+
const nameAlias = `#${attr}`;
|
|
145
|
+
const valAlias = `:${attr}`;
|
|
146
|
+
names[nameAlias] = attr;
|
|
147
|
+
values[valAlias] = val;
|
|
148
|
+
clauses.push(`${nameAlias} = ${valAlias}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
params.FilterExpression = clauses.join(' AND ');
|
|
152
|
+
params.ExpressionAttributeNames = names;
|
|
153
|
+
params.ExpressionAttributeValues = values;
|
|
144
154
|
}
|
|
145
|
-
|
|
146
|
-
params.FilterExpression = clauses.join(' AND ');
|
|
147
|
-
params.ExpressionAttributeNames = names;
|
|
148
|
-
params.ExpressionAttributeValues = values;
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
return params;
|
|
@@ -162,11 +168,19 @@ export function buildQuery(
|
|
|
162
168
|
keyConditions: Record<string, unknown>,
|
|
163
169
|
exclusiveStartKey?: Record<string, unknown>,
|
|
164
170
|
): QueryParams {
|
|
171
|
+
const validEntries = Object.entries(keyConditions).filter(
|
|
172
|
+
([, val]) => val !== undefined && val !== null,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (validEntries.length === 0) {
|
|
176
|
+
throw new Error('buildQuery: all keyCondition values are undefined/null');
|
|
177
|
+
}
|
|
178
|
+
|
|
165
179
|
const names: Record<string, string> = {};
|
|
166
180
|
const values: Record<string, unknown> = {};
|
|
167
181
|
const clauses: string[] = [];
|
|
168
182
|
|
|
169
|
-
for (const [attr, val] of
|
|
183
|
+
for (const [attr, val] of validEntries) {
|
|
170
184
|
const nameAlias = `#${attr}`;
|
|
171
185
|
const valAlias = `:${attr}`;
|
|
172
186
|
names[nameAlias] = attr;
|
package/src/main.ts
CHANGED
|
@@ -20,7 +20,6 @@ import log from 'stonyx/log';
|
|
|
20
20
|
import { forEachFileImport } from '@stonyx/utils/file';
|
|
21
21
|
import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
|
|
22
22
|
import { registerPluralName } from './plural-registry.js';
|
|
23
|
-
import setupRestServer from './setup-rest-server.js';
|
|
24
23
|
import baseTransforms from './transforms.js';
|
|
25
24
|
import Store from './store.js';
|
|
26
25
|
import Serializer from './serializer.js';
|
|
@@ -177,6 +176,25 @@ export default class Orm {
|
|
|
177
176
|
}
|
|
178
177
|
|
|
179
178
|
if (restServer.enabled === 'true') {
|
|
179
|
+
// MUST stay dynamic. setup-rest-server.js names the optional
|
|
180
|
+
// '@stonyx/rest-server' peer in its own static graph — directly, and
|
|
181
|
+
// through orm-request.ts / meta-request.ts, which import `Request` at
|
|
182
|
+
// module scope because they extend it (correctly: an `extends` base
|
|
183
|
+
// class cannot be awaited). Node links a module's entire static graph
|
|
184
|
+
// before evaluating any of it, so a static import here puts that
|
|
185
|
+
// specifier on the entry graph and `import('@stonyx/orm')` throws
|
|
186
|
+
// ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
|
|
187
|
+
// optional peer.
|
|
188
|
+
//
|
|
189
|
+
// NOT the same reason the SQL/DynamoDB drivers above are lazy: those
|
|
190
|
+
// modules carry no static peer specifier that survives to `dist/`
|
|
191
|
+
// (postgres-db.ts:15 and mysql-db.ts:17 are `import type`, erased by
|
|
192
|
+
// tsc), so the `await import()` there is not what isolates pg / mysql2 /
|
|
193
|
+
// @aws-sdk — that happens one layer down, in src/*/connection.ts (and
|
|
194
|
+
// src/dynamodb/dynamodb-db.ts).
|
|
195
|
+
// setup-rest-server.js is the only dist module whose laziness is
|
|
196
|
+
// load-bearing for peer resolution. (#280)
|
|
197
|
+
const { default: setupRestServer } = await import('./setup-rest-server.js');
|
|
180
198
|
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
181
199
|
}
|
|
182
200
|
|
package/src/manage-record.ts
CHANGED
|
@@ -79,6 +79,28 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
79
79
|
pendingHasMany.splice(0);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// FK-based inverse hasMany wiring — when a child record is created with a
|
|
83
|
+
// foreign-key field (e.g. `owner: 'owner-1'` on an animal), find any parent
|
|
84
|
+
// whose hasMany registry targets this model and push the child into the
|
|
85
|
+
// parent's shared array. This covers edge cases where the child is created
|
|
86
|
+
// in a separate async frame without a belongsTo handler firing.
|
|
87
|
+
const hasManyReg = getHasManyRegistry();
|
|
88
|
+
if (hasManyReg) {
|
|
89
|
+
for (const [parentModelName, targetMap] of hasManyReg) {
|
|
90
|
+
const childArrayMap = targetMap.get(modelName);
|
|
91
|
+
if (!childArrayMap) continue;
|
|
92
|
+
|
|
93
|
+
// Check if rawData contains a FK field matching the parent model name
|
|
94
|
+
const fkValue = rawData[parentModelName];
|
|
95
|
+
if (fkValue === undefined || fkValue === null) continue;
|
|
96
|
+
|
|
97
|
+
const parentArray = childArrayMap.get(fkValue);
|
|
98
|
+
if (parentArray && !parentArray.includes(record)) {
|
|
99
|
+
parentArray.push(record);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
82
104
|
// Fulfill pending belongsTo relationships
|
|
83
105
|
const pendingBelongsToQueue = getPendingBelongsToRegistry();
|
|
84
106
|
const pendingBelongsToRaw = pendingBelongsToQueue.get(modelName)?.get(record.id);
|
|
@@ -86,7 +108,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
86
108
|
|
|
87
109
|
if (pendingBelongsTo) {
|
|
88
110
|
const belongsToReg = getBelongsToRegistry();
|
|
89
|
-
const
|
|
111
|
+
const pendingHasManyReg = getHasManyRegistry();
|
|
90
112
|
|
|
91
113
|
for (const { sourceRecord, sourceModelName, relationshipKey, relationshipId } of pendingBelongsTo) {
|
|
92
114
|
// Update the belongsTo relationship on the source record
|
|
@@ -103,7 +125,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
103
125
|
}
|
|
104
126
|
|
|
105
127
|
// Wire inverse hasMany if it exists
|
|
106
|
-
const inverseHasMany =
|
|
128
|
+
const inverseHasMany = pendingHasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
|
|
107
129
|
|
|
108
130
|
if (inverseHasMany && !inverseHasMany.includes(sourceRecord)) {
|
|
109
131
|
inverseHasMany.push(sourceRecord);
|
|
@@ -117,15 +139,25 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
117
139
|
// Auto-persist to SQL — skip for DB loads (isDbRecord) and relationship resolution (_relationshipKey)
|
|
118
140
|
const shouldPersist = orm?.sqlDb && !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
|
|
119
141
|
if (shouldPersist) {
|
|
142
|
+
// Capture ID before persist — SQL adapters re-key pending IDs to real DB IDs,
|
|
143
|
+
// but relationship registries were keyed with this original ID
|
|
144
|
+
const registryId = record.id;
|
|
120
145
|
const response = { data: { id: record.id } };
|
|
121
|
-
orm!.sqlDb!.persist('create', modelName, { rawData }, response)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
146
|
+
orm!.sqlDb!.persist('create', modelName, { rawData }, response)
|
|
147
|
+
.catch((err: unknown) => {
|
|
148
|
+
orm!.emitPersistError({
|
|
149
|
+
operation: 'create',
|
|
150
|
+
modelName,
|
|
151
|
+
recordId: record.id,
|
|
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
|
+
}
|
|
127
160
|
});
|
|
128
|
-
});
|
|
129
161
|
}
|
|
130
162
|
|
|
131
163
|
return record;
|
package/src/mysql/connection.ts
CHANGED
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;
|
|
@@ -118,7 +127,15 @@ export default class MysqlDB {
|
|
|
118
127
|
if (pending.length > 0) {
|
|
119
128
|
this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
|
|
120
129
|
|
|
121
|
-
|
|
130
|
+
let shouldApply: boolean;
|
|
131
|
+
if (this.mysqlConfig.autoMigrate === true) {
|
|
132
|
+
shouldApply = true;
|
|
133
|
+
} else if (this.mysqlConfig.autoMigrate === false) {
|
|
134
|
+
shouldApply = false;
|
|
135
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
|
|
136
|
+
} else {
|
|
137
|
+
shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
|
|
138
|
+
}
|
|
122
139
|
|
|
123
140
|
if (shouldApply) {
|
|
124
141
|
for (const filename of pending) {
|
|
@@ -139,9 +156,17 @@ export default class MysqlDB {
|
|
|
139
156
|
const modelCount = Object.keys(schemas).length;
|
|
140
157
|
|
|
141
158
|
if (modelCount > 0) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
159
|
+
let shouldGenerate: boolean;
|
|
160
|
+
if (this.mysqlConfig.autoMigrate === true) {
|
|
161
|
+
shouldGenerate = true;
|
|
162
|
+
} else if (this.mysqlConfig.autoMigrate === false) {
|
|
163
|
+
shouldGenerate = false;
|
|
164
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
|
|
165
|
+
} else {
|
|
166
|
+
shouldGenerate = await this.deps.confirm(
|
|
167
|
+
`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
145
170
|
|
|
146
171
|
if (shouldGenerate) {
|
|
147
172
|
const { generateMigration } = await import('./migration-generator.js');
|
|
@@ -398,14 +423,21 @@ export default class MysqlDB {
|
|
|
398
423
|
const Orm = (await import('@stonyx/orm')).default;
|
|
399
424
|
if ((Orm as unknown as { instance?: { isView?: (name: string) => boolean } }).instance?.isView?.(modelName)) return;
|
|
400
425
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
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;
|
|
409
441
|
}
|
|
410
442
|
|
|
411
443
|
private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
|
package/src/orm-request.ts
CHANGED
|
@@ -10,6 +10,10 @@ import { isOrmRecord } from './utils.js';
|
|
|
10
10
|
|
|
11
11
|
interface OrmRequest$ extends Request {
|
|
12
12
|
protocol?: string;
|
|
13
|
+
// Express sets this to the path the router was mounted at, e.g. '/api/animals'
|
|
14
|
+
// when orm.restServer.route is '/api'. Optional because non-Express callers
|
|
15
|
+
// (unit tests, programmatic handler invocation) do not supply it.
|
|
16
|
+
baseUrl?: string;
|
|
13
17
|
method: string;
|
|
14
18
|
params: { [key: string]: string };
|
|
15
19
|
body?: { [key: string]: unknown };
|
|
@@ -77,11 +81,30 @@ function getModelRelationships(modelName: string): { [key: string]: Relationship
|
|
|
77
81
|
return relationships;
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Build the absolute base URL that every advertised link hangs off — origin
|
|
86
|
+
* plus the prefix the ORM's routes are actually mounted at.
|
|
87
|
+
*
|
|
88
|
+
* The prefix is derived from the *request*, not from
|
|
89
|
+
* `config.orm.restServer.route`. Express sets `request.baseUrl` to the real
|
|
90
|
+
* mountpath registered by `RestServer.mountRoute`, which for this module is
|
|
91
|
+
* always `<prefix>/<pluralizedModel>` (see setup-rest-server.ts). Stripping the
|
|
92
|
+
* trailing model segment therefore yields the prefix by construction, and the
|
|
93
|
+
* link builder cannot drift from the mount registrar the way a second,
|
|
94
|
+
* independent normalisation of `route` would.
|
|
95
|
+
*
|
|
96
|
+
* When `request.baseUrl` is absent or does not end in the model segment the
|
|
97
|
+
* prefix is empty, which reproduces the previous origin-only behaviour.
|
|
98
|
+
*/
|
|
99
|
+
function getBaseUrl(request: OrmRequest$, pluralizedModel: string): string {
|
|
82
100
|
const protocol = request.protocol || 'http';
|
|
83
101
|
const host = request.get('host');
|
|
84
|
-
|
|
102
|
+
|
|
103
|
+
const modelSegment = `/${pluralizedModel}`;
|
|
104
|
+
const mountPath = request.baseUrl ?? '';
|
|
105
|
+
const prefix = mountPath.endsWith(modelSegment) ? mountPath.slice(0, -modelSegment.length) : '';
|
|
106
|
+
|
|
107
|
+
return `${protocol}://${host}${prefix}`;
|
|
85
108
|
}
|
|
86
109
|
|
|
87
110
|
function getId(params: { id?: string; [key: string]: unknown }): string | number {
|
|
@@ -278,7 +301,7 @@ export default class OrmRequest extends Request {
|
|
|
278
301
|
if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter as (record: OrmRecord) => boolean);
|
|
279
302
|
if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
|
|
280
303
|
|
|
281
|
-
const baseUrl = getBaseUrl(request);
|
|
304
|
+
const baseUrl = getBaseUrl(request, pluralizedModel);
|
|
282
305
|
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
|
|
283
306
|
|
|
284
307
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
@@ -294,7 +317,7 @@ export default class OrmRequest extends Request {
|
|
|
294
317
|
const fieldsMap = parseFields(request.query);
|
|
295
318
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
296
319
|
|
|
297
|
-
const baseUrl = getBaseUrl(request);
|
|
320
|
+
const baseUrl = getBaseUrl(request, pluralizedModel);
|
|
298
321
|
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
|
|
299
322
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
300
323
|
baseUrl
|
|
@@ -376,7 +399,7 @@ export default class OrmRequest extends Request {
|
|
|
376
399
|
};
|
|
377
400
|
|
|
378
401
|
const deleteHandler: HandlerFn = ({ params }) => {
|
|
379
|
-
store.remove(model, getId(params));
|
|
402
|
+
store.remove(model, getId(params), { _skipAutoPersist: true });
|
|
380
403
|
return 204;
|
|
381
404
|
};
|
|
382
405
|
|
|
@@ -443,9 +466,14 @@ export default class OrmRequest extends Request {
|
|
|
443
466
|
// Execute main handler
|
|
444
467
|
const response = await handler(request, state);
|
|
445
468
|
|
|
446
|
-
//
|
|
469
|
+
// Set context.record for update BEFORE persist so SQL drivers can read it
|
|
470
|
+
if (operation === 'update' && (response as JsonApiResponse)?.data) {
|
|
471
|
+
context.record = store.get(this.model, getId(request.params));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Persist to SQL database for all write operations (create/update/delete)
|
|
447
475
|
const sqlDb = Orm.instance.sqlDb;
|
|
448
|
-
if (sqlDb && (operation
|
|
476
|
+
if (sqlDb && WRITE_OPERATIONS.has(operation)) {
|
|
449
477
|
await sqlDb.persist(operation, this.model, context, response);
|
|
450
478
|
}
|
|
451
479
|
|
|
@@ -461,8 +489,6 @@ export default class OrmRequest extends Request {
|
|
|
461
489
|
const responseData = (response as { data: { id: string | number } }).data;
|
|
462
490
|
const recordId = isNaN(responseData.id as unknown as number) ? responseData.id : parseInt(responseData.id as string);
|
|
463
491
|
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
492
|
} else if (operation === 'delete') {
|
|
467
493
|
// For delete, the record may no longer exist, but we have oldState
|
|
468
494
|
context.recordId = getId(request.params);
|
|
@@ -499,7 +525,7 @@ export default class OrmRequest extends Request {
|
|
|
499
525
|
if (!record) return 404;
|
|
500
526
|
|
|
501
527
|
const relatedData = record.__relationships[relationshipName];
|
|
502
|
-
const baseUrl = getBaseUrl(request);
|
|
528
|
+
const baseUrl = getBaseUrl(request, pluralizedModel);
|
|
503
529
|
|
|
504
530
|
let data: unknown;
|
|
505
531
|
if (info.isArray) {
|
|
@@ -523,7 +549,7 @@ export default class OrmRequest extends Request {
|
|
|
523
549
|
if (!record) return 404;
|
|
524
550
|
|
|
525
551
|
const relatedData = record.__relationships[relationshipName];
|
|
526
|
-
const baseUrl = getBaseUrl(request);
|
|
552
|
+
const baseUrl = getBaseUrl(request, pluralizedModel);
|
|
527
553
|
|
|
528
554
|
let data: unknown;
|
|
529
555
|
if (info.isArray) {
|
|
@@ -8,6 +8,7 @@ interface PgConfig {
|
|
|
8
8
|
password: string;
|
|
9
9
|
database: string;
|
|
10
10
|
connectionLimit: number;
|
|
11
|
+
[key: string]: unknown;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
let pool: PgPool | null = null;
|
|
@@ -20,15 +21,18 @@ export async function getPool(pgConfig: PgConfig, extensions: string[] = ['vecto
|
|
|
20
21
|
|
|
21
22
|
const { default: pg } = await import('pg');
|
|
22
23
|
|
|
24
|
+
const { host, port, user, password, database, connectionLimit, migrationsDir, migrationsTable, autoMigrate, ...poolOpts } = pgConfig;
|
|
25
|
+
|
|
23
26
|
pool = new pg.Pool({
|
|
24
|
-
host
|
|
25
|
-
port
|
|
26
|
-
user
|
|
27
|
-
password
|
|
28
|
-
database
|
|
29
|
-
max:
|
|
27
|
+
host,
|
|
28
|
+
port,
|
|
29
|
+
user,
|
|
30
|
+
password,
|
|
31
|
+
database,
|
|
32
|
+
max: connectionLimit,
|
|
30
33
|
idleTimeoutMillis: 30000,
|
|
31
34
|
connectionTimeoutMillis: 10000,
|
|
35
|
+
...poolOpts,
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
// Enable requested PostgreSQL extensions
|