@stonyx/orm 0.3.2-beta.16 → 0.3.2-beta.161
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 +36 -2
- package/config/environment.js +99 -12
- 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/main.js +10 -0
- 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.js +7 -6
- 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 +11 -0
- package/package.json +16 -7
- 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/main.ts +10 -0
- 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 +8 -5
- 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 +12 -0
- package/src/types/stonyx.d.ts +7 -1
- package/config/environment.ts +0 -91
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB operation parameter builders.
|
|
3
|
+
*
|
|
4
|
+
* Each function returns a plain-object "params" bag that can be passed
|
|
5
|
+
* directly to the corresponding DocumentClient command
|
|
6
|
+
* (PutCommand, GetCommand, UpdateCommand, DeleteCommand, ScanCommand, QueryCommand).
|
|
7
|
+
*
|
|
8
|
+
* All functions are pure — no SDK imports here; the caller wraps params
|
|
9
|
+
* in the appropriate Command class.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface PutItemParams {
|
|
13
|
+
TableName: string;
|
|
14
|
+
Item: Record<string, unknown>;
|
|
15
|
+
ConditionExpression?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GetItemParams {
|
|
19
|
+
TableName: string;
|
|
20
|
+
Key: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UpdateItemParams {
|
|
24
|
+
TableName: string;
|
|
25
|
+
Key: Record<string, unknown>;
|
|
26
|
+
UpdateExpression: string;
|
|
27
|
+
ExpressionAttributeNames: Record<string, string>;
|
|
28
|
+
ExpressionAttributeValues: Record<string, unknown>;
|
|
29
|
+
ReturnValues: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DeleteItemParams {
|
|
33
|
+
TableName: string;
|
|
34
|
+
Key: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ScanParams {
|
|
38
|
+
TableName: string;
|
|
39
|
+
FilterExpression?: string;
|
|
40
|
+
ExpressionAttributeNames?: Record<string, string>;
|
|
41
|
+
ExpressionAttributeValues?: Record<string, unknown>;
|
|
42
|
+
ExclusiveStartKey?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface QueryParams {
|
|
46
|
+
TableName: string;
|
|
47
|
+
IndexName: string;
|
|
48
|
+
KeyConditionExpression: string;
|
|
49
|
+
ExpressionAttributeNames: Record<string, string>;
|
|
50
|
+
ExpressionAttributeValues: Record<string, unknown>;
|
|
51
|
+
ExclusiveStartKey?: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* PutItem — optionally with a condition expression.
|
|
56
|
+
*
|
|
57
|
+
* Pass conditionExpression = 'attribute_not_exists(id)' to enforce uniqueness.
|
|
58
|
+
*/
|
|
59
|
+
export function buildPutItem(
|
|
60
|
+
tableName: string,
|
|
61
|
+
item: Record<string, unknown>,
|
|
62
|
+
conditionExpression?: string,
|
|
63
|
+
): PutItemParams {
|
|
64
|
+
const params: PutItemParams = { TableName: tableName, Item: item };
|
|
65
|
+
if (conditionExpression) params.ConditionExpression = conditionExpression;
|
|
66
|
+
return params;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* GetItem by primary key.
|
|
71
|
+
*/
|
|
72
|
+
export function buildGetItem(
|
|
73
|
+
tableName: string,
|
|
74
|
+
key: Record<string, unknown>,
|
|
75
|
+
): GetItemParams {
|
|
76
|
+
return { TableName: tableName, Key: key };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* UpdateItem with a SET expression built from the `updates` object.
|
|
81
|
+
* Only the supplied attributes are updated (diff-based call site).
|
|
82
|
+
*/
|
|
83
|
+
export function buildUpdateItem(
|
|
84
|
+
tableName: string,
|
|
85
|
+
key: Record<string, unknown>,
|
|
86
|
+
updates: Record<string, unknown>,
|
|
87
|
+
): UpdateItemParams {
|
|
88
|
+
const names: Record<string, string> = {};
|
|
89
|
+
const values: Record<string, unknown> = {};
|
|
90
|
+
const setClauses: string[] = [];
|
|
91
|
+
|
|
92
|
+
for (const [attr, val] of Object.entries(updates)) {
|
|
93
|
+
const nameAlias = `#${attr}`;
|
|
94
|
+
const valAlias = `:${attr}`;
|
|
95
|
+
names[nameAlias] = attr;
|
|
96
|
+
values[valAlias] = val;
|
|
97
|
+
setClauses.push(`${nameAlias} = ${valAlias}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
TableName: tableName,
|
|
102
|
+
Key: key,
|
|
103
|
+
UpdateExpression: `SET ${setClauses.join(', ')}`,
|
|
104
|
+
ExpressionAttributeNames: names,
|
|
105
|
+
ExpressionAttributeValues: values,
|
|
106
|
+
ReturnValues: 'NONE',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* DeleteItem by primary key.
|
|
112
|
+
*/
|
|
113
|
+
export function buildDeleteItem(
|
|
114
|
+
tableName: string,
|
|
115
|
+
key: Record<string, unknown>,
|
|
116
|
+
): DeleteItemParams {
|
|
117
|
+
return { TableName: tableName, Key: key };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* ScanCommand params.
|
|
122
|
+
* If conditions are supplied they are rendered as a FilterExpression using AND.
|
|
123
|
+
*/
|
|
124
|
+
export function buildScan(
|
|
125
|
+
tableName: string,
|
|
126
|
+
conditions?: Record<string, unknown>,
|
|
127
|
+
exclusiveStartKey?: Record<string, unknown>,
|
|
128
|
+
): ScanParams {
|
|
129
|
+
const params: ScanParams = { TableName: tableName };
|
|
130
|
+
|
|
131
|
+
if (exclusiveStartKey) params.ExclusiveStartKey = exclusiveStartKey;
|
|
132
|
+
|
|
133
|
+
if (conditions && Object.keys(conditions).length > 0) {
|
|
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;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return params;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* QueryCommand params for a GSI.
|
|
162
|
+
* keyConditions must be in the form { attrName: value } and will be rendered
|
|
163
|
+
* as equality expressions joined by AND.
|
|
164
|
+
*/
|
|
165
|
+
export function buildQuery(
|
|
166
|
+
tableName: string,
|
|
167
|
+
indexName: string,
|
|
168
|
+
keyConditions: Record<string, unknown>,
|
|
169
|
+
exclusiveStartKey?: Record<string, unknown>,
|
|
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
|
+
|
|
179
|
+
const names: Record<string, string> = {};
|
|
180
|
+
const values: Record<string, unknown> = {};
|
|
181
|
+
const clauses: string[] = [];
|
|
182
|
+
|
|
183
|
+
for (const [attr, val] of validEntries) {
|
|
184
|
+
const nameAlias = `#${attr}`;
|
|
185
|
+
const valAlias = `:${attr}`;
|
|
186
|
+
names[nameAlias] = attr;
|
|
187
|
+
values[valAlias] = val;
|
|
188
|
+
clauses.push(`${nameAlias} = ${valAlias}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const params: QueryParams = {
|
|
192
|
+
TableName: tableName,
|
|
193
|
+
IndexName: indexName,
|
|
194
|
+
KeyConditionExpression: clauses.join(' AND '),
|
|
195
|
+
ExpressionAttributeNames: names,
|
|
196
|
+
ExpressionAttributeValues: values,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (exclusiveStartKey) params.ExclusiveStartKey = exclusiveStartKey;
|
|
200
|
+
|
|
201
|
+
return params;
|
|
202
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps ORM attribute types to DynamoDB scalar attribute types.
|
|
3
|
+
* DynamoDB DocumentClient auto-marshalls JS objects, so most values
|
|
4
|
+
* are sent as their native JS types. This map is used by the
|
|
5
|
+
* schema-introspector and startup provisioner for table/GSI creation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type DynamoScalarType = 'S' | 'N' | 'BOOL';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* DynamoDB attribute-type string for a given ORM attr type.
|
|
12
|
+
* - string → S
|
|
13
|
+
* - number / float → N (stored as Number; DocumentClient handles it)
|
|
14
|
+
* - boolean → BOOL
|
|
15
|
+
* - date → S (ISO-8601 string — enables range queries)
|
|
16
|
+
* - timestamp → N (milliseconds since epoch)
|
|
17
|
+
* - passthrough/trim/etc → S (safe default)
|
|
18
|
+
*
|
|
19
|
+
* For key schema declarations only `S` and `N` are valid; BOOL
|
|
20
|
+
* is legal for attributes but never for a PK/SK.
|
|
21
|
+
*/
|
|
22
|
+
const typeMap: Record<string, DynamoScalarType> = {
|
|
23
|
+
string: 'S',
|
|
24
|
+
number: 'N',
|
|
25
|
+
float: 'N',
|
|
26
|
+
boolean: 'BOOL',
|
|
27
|
+
date: 'S',
|
|
28
|
+
timestamp: 'N',
|
|
29
|
+
passthrough: 'S',
|
|
30
|
+
trim: 'S',
|
|
31
|
+
uppercase: 'S',
|
|
32
|
+
ceil: 'N',
|
|
33
|
+
floor: 'N',
|
|
34
|
+
round: 'N',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the DynamoDB attribute type for a given ORM type string.
|
|
39
|
+
* Defaults to 'S' for any unknown/custom type.
|
|
40
|
+
*/
|
|
41
|
+
export function getDynamoType(attrType: string): DynamoScalarType {
|
|
42
|
+
return typeMap[attrType] ?? 'S';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns the DynamoDB key type ('S' | 'N') for use in KeySchema.
|
|
47
|
+
* BOOL cannot be a key attribute; anything that maps to BOOL falls back to 'S'.
|
|
48
|
+
*/
|
|
49
|
+
export function getDynamoKeyType(attrType: string): 'S' | 'N' {
|
|
50
|
+
const t = getDynamoType(attrType);
|
|
51
|
+
return t === 'N' ? 'N' : 'S';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default typeMap;
|
package/src/main.ts
CHANGED
|
@@ -90,6 +90,11 @@ export default class Orm {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
async init(): Promise<void> {
|
|
93
|
+
// Self-register so log.db works even when @stonyx/orm is in the
|
|
94
|
+
// consumer's `dependencies` (stonyx loader only merges devDependencies).
|
|
95
|
+
const { logColor = 'white', logMethod = 'db' } = config.orm;
|
|
96
|
+
log.defineType(logMethod, logColor);
|
|
97
|
+
|
|
93
98
|
const { paths, restServer } = config.orm;
|
|
94
99
|
|
|
95
100
|
const promises: Promise<unknown>[] = ['Model', 'Serializer', 'Transform'].map(type => {
|
|
@@ -159,6 +164,11 @@ export default class Orm {
|
|
|
159
164
|
this.sqlDb = new MysqlDB() as SqlDb;
|
|
160
165
|
this.db = this.sqlDb;
|
|
161
166
|
promises.push(this.sqlDb.init());
|
|
167
|
+
} else if (config.orm.dynamodb) {
|
|
168
|
+
const { default: DynamoDBDB } = await import('./dynamodb/dynamodb-db.js');
|
|
169
|
+
this.sqlDb = new DynamoDBDB() as SqlDb;
|
|
170
|
+
this.db = this.sqlDb;
|
|
171
|
+
promises.push(this.sqlDb.init());
|
|
162
172
|
} else if (this.options.dbType !== 'none') {
|
|
163
173
|
const db = new DB();
|
|
164
174
|
this.db = db;
|
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
|
@@ -376,7 +376,7 @@ export default class OrmRequest extends Request {
|
|
|
376
376
|
};
|
|
377
377
|
|
|
378
378
|
const deleteHandler: HandlerFn = ({ params }) => {
|
|
379
|
-
store.remove(model, getId(params));
|
|
379
|
+
store.remove(model, getId(params), { _skipAutoPersist: true });
|
|
380
380
|
return 204;
|
|
381
381
|
};
|
|
382
382
|
|
|
@@ -443,9 +443,14 @@ export default class OrmRequest extends Request {
|
|
|
443
443
|
// Execute main handler
|
|
444
444
|
const response = await handler(request, state);
|
|
445
445
|
|
|
446
|
-
//
|
|
446
|
+
// Set context.record for update BEFORE persist so SQL drivers can read it
|
|
447
|
+
if (operation === 'update' && (response as JsonApiResponse)?.data) {
|
|
448
|
+
context.record = store.get(this.model, getId(request.params));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Persist to SQL database for all write operations (create/update/delete)
|
|
447
452
|
const sqlDb = Orm.instance.sqlDb;
|
|
448
|
-
if (sqlDb && (operation
|
|
453
|
+
if (sqlDb && WRITE_OPERATIONS.has(operation)) {
|
|
449
454
|
await sqlDb.persist(operation, this.model, context, response);
|
|
450
455
|
}
|
|
451
456
|
|
|
@@ -461,8 +466,6 @@ export default class OrmRequest extends Request {
|
|
|
461
466
|
const responseData = (response as { data: { id: string | number } }).data;
|
|
462
467
|
const recordId = isNaN(responseData.id as unknown as number) ? responseData.id : parseInt(responseData.id as string);
|
|
463
468
|
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
469
|
} else if (operation === 'delete') {
|
|
467
470
|
// For delete, the record may no longer exist, but we have oldState
|
|
468
471
|
context.recordId = getId(request.params);
|
|
@@ -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
|
|
@@ -90,6 +90,15 @@ export default class PostgresDB {
|
|
|
90
90
|
pool!: Pool | null;
|
|
91
91
|
pgConfig!: Record<string, unknown>;
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Promise-chain mutex for write serialization (#156).
|
|
95
|
+
* All persist() calls chain through this single queue so concurrent
|
|
96
|
+
* fire-and-forget writes never produce parallel transactions
|
|
97
|
+
* on FK-linked rows (which cause deadlocks).
|
|
98
|
+
* Reads are NOT affected — only persist() serializes.
|
|
99
|
+
*/
|
|
100
|
+
private _writeQueue: Promise<void> = Promise.resolve();
|
|
101
|
+
|
|
93
102
|
constructor(deps: Partial<PostgresDeps> = {}) {
|
|
94
103
|
const Ctor = this.constructor as typeof PostgresDB;
|
|
95
104
|
if (Ctor.instance) return Ctor.instance;
|
|
@@ -125,7 +134,15 @@ export default class PostgresDB {
|
|
|
125
134
|
if (pending.length > 0) {
|
|
126
135
|
this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
|
|
127
136
|
|
|
128
|
-
|
|
137
|
+
let shouldApply: boolean;
|
|
138
|
+
if (this.pgConfig.autoMigrate === true) {
|
|
139
|
+
shouldApply = true;
|
|
140
|
+
} else if (this.pgConfig.autoMigrate === false) {
|
|
141
|
+
shouldApply = false;
|
|
142
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
|
|
143
|
+
} else {
|
|
144
|
+
shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
|
|
145
|
+
}
|
|
129
146
|
|
|
130
147
|
if (shouldApply) {
|
|
131
148
|
for (const filename of pending) {
|
|
@@ -146,9 +163,17 @@ export default class PostgresDB {
|
|
|
146
163
|
const modelCount = Object.keys(schemas).length;
|
|
147
164
|
|
|
148
165
|
if (modelCount > 0) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
166
|
+
let shouldGenerate: boolean;
|
|
167
|
+
if (this.pgConfig.autoMigrate === true) {
|
|
168
|
+
shouldGenerate = true;
|
|
169
|
+
} else if (this.pgConfig.autoMigrate === false) {
|
|
170
|
+
shouldGenerate = false;
|
|
171
|
+
this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
|
|
172
|
+
} else {
|
|
173
|
+
shouldGenerate = await this.deps.confirm(
|
|
174
|
+
`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
152
177
|
|
|
153
178
|
if (shouldGenerate) {
|
|
154
179
|
const { generateMigration } = await import('./migration-generator.js');
|
|
@@ -468,14 +493,21 @@ export default class PostgresDB {
|
|
|
468
493
|
const Orm = (await import('@stonyx/orm')).default;
|
|
469
494
|
if ((Orm.instance as { isView?: (name: string) => boolean })?.isView?.(modelName)) return;
|
|
470
495
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
496
|
+
const work = async () => {
|
|
497
|
+
switch (operation) {
|
|
498
|
+
case 'create':
|
|
499
|
+
return this._persistCreate(modelName, context, response);
|
|
500
|
+
case 'update':
|
|
501
|
+
return this._persistUpdate(modelName, context, response);
|
|
502
|
+
case 'delete':
|
|
503
|
+
return this._persistDelete(modelName, context);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// Chain through the write queue — .then(work, work) ensures the queue
|
|
508
|
+
// advances even when a previous persist rejects (#156).
|
|
509
|
+
this._writeQueue = this._writeQueue.then(work, work);
|
|
510
|
+
return this._writeQueue;
|
|
479
511
|
}
|
|
480
512
|
|
|
481
513
|
private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
|
package/src/record.ts
CHANGED
|
@@ -87,12 +87,15 @@ export default class Record {
|
|
|
87
87
|
|
|
88
88
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
89
89
|
if (Array.isArray(childRecord)) {
|
|
90
|
+
// Filter out cleaned records (those with no __model)
|
|
91
|
+
const live = childRecord.filter((r: Record) => r?.__model);
|
|
92
|
+
|
|
90
93
|
// Deduplicate by record ID — keep last occurrence (latest data wins)
|
|
91
94
|
const seen = new Set<unknown>();
|
|
92
95
|
const unique: Record[] = [];
|
|
93
96
|
|
|
94
|
-
for (let i =
|
|
95
|
-
const r =
|
|
97
|
+
for (let i = live.length - 1; i >= 0; i--) {
|
|
98
|
+
const r = live[i] as Record;
|
|
96
99
|
if (!seen.has(r.id)) {
|
|
97
100
|
seen.add(r.id);
|
|
98
101
|
unique.push(r);
|
|
@@ -102,7 +105,7 @@ export default class Record {
|
|
|
102
105
|
unique.reverse();
|
|
103
106
|
records[key] = unique.map((r: Record) => r.serialize());
|
|
104
107
|
} else {
|
|
105
|
-
records[key] = (childRecord as Record)?.serialize()
|
|
108
|
+
records[key] = (childRecord as Record)?.__model ? (childRecord as Record).serialize() : null;
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
@@ -136,8 +139,8 @@ export default class Record {
|
|
|
136
139
|
if (fields && !fields.has(key)) continue;
|
|
137
140
|
|
|
138
141
|
const relationshipData = Array.isArray(childRecord)
|
|
139
|
-
? childRecord.map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
140
|
-
: childRecord ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
142
|
+
? childRecord.filter((r: Record) => r?.__model).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
143
|
+
: (childRecord && (childRecord as Record).__model) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
141
144
|
|
|
142
145
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
143
146
|
const dasherizedKey = camelCaseToKebabCase(key);
|
package/src/relationships.ts
CHANGED
|
@@ -51,4 +51,4 @@ export function getPendingBelongsToRegistry(): PendingBelongsToMap {
|
|
|
51
51
|
return relationships.get('pendingBelongsTo') as PendingBelongsToMap;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export const TYPES: string[] = ['global', 'hasMany', 'belongsTo', 'pending'];
|
|
54
|
+
export const TYPES: string[] = ['global', 'hasMany', 'belongsTo', 'pending', 'pendingBelongsTo'];
|