@stonyx/orm 0.3.2-alpha.3 → 0.3.2-alpha.31
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 +35 -2
- package/config/environment.js +8 -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 +132 -0
- package/dist/dynamodb/dynamodb-db.js +586 -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 +1 -0
- package/dist/main.d.ts +16 -0
- package/dist/main.js +36 -0
- package/dist/manage-record.js +51 -8
- package/dist/mysql/mysql-db.d.ts +1 -0
- package/dist/mysql/mysql-db.js +4 -2
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.js +4 -2
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/store.d.ts +10 -0
- package/dist/store.js +66 -2
- package/dist/types/orm-types.d.ts +9 -0
- package/package.json +17 -7
- package/src/commands.ts +43 -0
- package/src/dynamodb/connection.ts +50 -0
- package/src/dynamodb/dynamodb-db.ts +801 -0
- package/src/dynamodb/operation-builder.ts +202 -0
- package/src/dynamodb/type-map.ts +54 -0
- package/src/index.ts +1 -0
- package/src/main.ts +45 -0
- package/src/manage-record.ts +54 -9
- package/src/mysql/mysql-db.ts +5 -2
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +4 -2
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/store.ts +69 -2
- package/src/types/orm-types.ts +10 -0
- package/src/types/stonyx.d.ts +7 -1
|
@@ -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/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ 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
|
+
export type { PersistErrorDetail } from './main.js';
|
|
29
30
|
export { Model, View, Serializer }; // base classes
|
|
30
31
|
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
|
31
32
|
export { count, avg, sum, min, max }; // aggregate helpers
|
package/src/main.ts
CHANGED
|
@@ -49,6 +49,13 @@ const defaultOptions: OrmOptions = {
|
|
|
49
49
|
dbType: 'json'
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export interface PersistErrorDetail {
|
|
53
|
+
operation: 'create' | 'update' | 'delete';
|
|
54
|
+
modelName: string;
|
|
55
|
+
recordId: unknown;
|
|
56
|
+
error: Error;
|
|
57
|
+
}
|
|
58
|
+
|
|
52
59
|
export default class Orm {
|
|
53
60
|
static initialized: boolean = false;
|
|
54
61
|
static relationships: Map<string, Map<string, unknown>> = new Map();
|
|
@@ -65,6 +72,8 @@ export default class Orm {
|
|
|
65
72
|
sqlDb?: SqlDb;
|
|
66
73
|
db?: OrmDB | SqlDb;
|
|
67
74
|
|
|
75
|
+
private _persistErrorHandler: ((detail: PersistErrorDetail) => void) | null = null;
|
|
76
|
+
|
|
68
77
|
constructor(options: OrmOptions = {}) {
|
|
69
78
|
if (Orm.instance) return Orm.instance;
|
|
70
79
|
|
|
@@ -81,6 +90,11 @@ export default class Orm {
|
|
|
81
90
|
}
|
|
82
91
|
|
|
83
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
|
+
|
|
84
98
|
const { paths, restServer } = config.orm;
|
|
85
99
|
|
|
86
100
|
const promises: Promise<unknown>[] = ['Model', 'Serializer', 'Transform'].map(type => {
|
|
@@ -150,6 +164,11 @@ export default class Orm {
|
|
|
150
164
|
this.sqlDb = new MysqlDB() as SqlDb;
|
|
151
165
|
this.db = this.sqlDb;
|
|
152
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());
|
|
153
172
|
} else if (this.options.dbType !== 'none') {
|
|
154
173
|
const db = new DB();
|
|
155
174
|
this.db = db;
|
|
@@ -214,6 +233,32 @@ export default class Orm {
|
|
|
214
233
|
return !!this.views[`${modelClassPrefix}View`];
|
|
215
234
|
}
|
|
216
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Register a callback to be invoked when a fire-and-forget SQL persist fails.
|
|
238
|
+
* Without a handler, persist errors are logged via log.error (backwards-compatible).
|
|
239
|
+
*/
|
|
240
|
+
onPersistError(handler: ((detail: PersistErrorDetail) => void) | null): void {
|
|
241
|
+
this._persistErrorHandler = handler;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Emit a persist error to the registered handler, or fall back to log.error.
|
|
246
|
+
*/
|
|
247
|
+
emitPersistError(detail: PersistErrorDetail): void {
|
|
248
|
+
const fallbackLog = () => log.error?.(`[ORM] Failed to persist ${detail.operation} for ${detail.modelName}:${String(detail.recordId)}: ${detail.error.message}`);
|
|
249
|
+
|
|
250
|
+
if (this._persistErrorHandler) {
|
|
251
|
+
try {
|
|
252
|
+
this._persistErrorHandler(detail);
|
|
253
|
+
} catch (handlerError) {
|
|
254
|
+
fallbackLog();
|
|
255
|
+
log.error?.(`[ORM] onPersistError handler threw: ${handlerError instanceof Error ? handlerError.message : String(handlerError)}`);
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
fallbackLog();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
217
262
|
// Queue warnings to avoid the same error from being logged in the same iteration
|
|
218
263
|
warn(message: string): void {
|
|
219
264
|
this.warnings.add(message);
|
package/src/manage-record.ts
CHANGED
|
@@ -3,7 +3,6 @@ import OrmRecord from './record.js';
|
|
|
3
3
|
import { getGlobalRegistry, getPendingRegistry, getPendingBelongsToRegistry, getBelongsToRegistry, getHasManyRegistry } from './relationships.js';
|
|
4
4
|
import type Serializer from './serializer.js';
|
|
5
5
|
import { isOrmRecord } from './utils.js';
|
|
6
|
-
import log from 'stonyx/log';
|
|
7
6
|
|
|
8
7
|
interface CreateRecordOptions {
|
|
9
8
|
isDbRecord?: boolean;
|
|
@@ -27,6 +26,8 @@ const defaultOptions: CreateRecordOptions = {
|
|
|
27
26
|
transform: true
|
|
28
27
|
};
|
|
29
28
|
|
|
29
|
+
let pendingIdCounter = 0;
|
|
30
|
+
|
|
30
31
|
export function createRecord(modelName: string, rawData: { [key: string]: unknown } = {}, userOptions: CreateRecordOptions = {}): OrmRecord {
|
|
31
32
|
const orm = Orm.instance;
|
|
32
33
|
const { initialized } = Orm;
|
|
@@ -78,6 +79,28 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
78
79
|
pendingHasMany.splice(0);
|
|
79
80
|
}
|
|
80
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
|
+
|
|
81
104
|
// Fulfill pending belongsTo relationships
|
|
82
105
|
const pendingBelongsToQueue = getPendingBelongsToRegistry();
|
|
83
106
|
const pendingBelongsToRaw = pendingBelongsToQueue.get(modelName)?.get(record.id);
|
|
@@ -85,7 +108,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
85
108
|
|
|
86
109
|
if (pendingBelongsTo) {
|
|
87
110
|
const belongsToReg = getBelongsToRegistry();
|
|
88
|
-
const
|
|
111
|
+
const pendingHasManyReg = getHasManyRegistry();
|
|
89
112
|
|
|
90
113
|
for (const { sourceRecord, sourceModelName, relationshipKey, relationshipId } of pendingBelongsTo) {
|
|
91
114
|
// Update the belongsTo relationship on the source record
|
|
@@ -102,7 +125,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
102
125
|
}
|
|
103
126
|
|
|
104
127
|
// Wire inverse hasMany if it exists
|
|
105
|
-
const inverseHasMany =
|
|
128
|
+
const inverseHasMany = pendingHasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
|
|
106
129
|
|
|
107
130
|
if (inverseHasMany && !inverseHasMany.includes(sourceRecord)) {
|
|
108
131
|
inverseHasMany.push(sourceRecord);
|
|
@@ -116,10 +139,25 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
116
139
|
// Auto-persist to SQL — skip for DB loads (isDbRecord) and relationship resolution (_relationshipKey)
|
|
117
140
|
const shouldPersist = orm?.sqlDb && !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
|
|
118
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;
|
|
119
145
|
const response = { data: { id: record.id } };
|
|
120
|
-
orm!.sqlDb!.persist('create', modelName, { rawData }, response)
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
}
|
|
160
|
+
});
|
|
123
161
|
}
|
|
124
162
|
|
|
125
163
|
return record;
|
|
@@ -146,7 +184,12 @@ export function updateRecord(record: OrmRecord, rawData: unknown, userOptions: C
|
|
|
146
184
|
const shouldPersist = orm?.sqlDb && !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
|
|
147
185
|
if (shouldPersist && modelName) {
|
|
148
186
|
orm!.sqlDb!.persist('update', modelName, { record, oldState }, {}).catch((err: unknown) => {
|
|
149
|
-
|
|
187
|
+
orm!.emitPersistError({
|
|
188
|
+
operation: 'update',
|
|
189
|
+
modelName,
|
|
190
|
+
recordId: record.id,
|
|
191
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
192
|
+
});
|
|
150
193
|
});
|
|
151
194
|
}
|
|
152
195
|
}
|
|
@@ -160,9 +203,11 @@ export function updateRecord(record: OrmRecord, rawData: unknown, userOptions: C
|
|
|
160
203
|
function assignRecordId(modelName: string, rawData: { [key: string]: unknown }): void {
|
|
161
204
|
if (rawData.id) return;
|
|
162
205
|
|
|
163
|
-
// In SQL mode with numeric IDs, defer to database auto-increment
|
|
206
|
+
// In SQL mode with numeric IDs, defer to database auto-increment.
|
|
207
|
+
// Use unique negative integers — they survive the number transform (parseInt preserves negatives)
|
|
208
|
+
// and avoid NaN store-key collisions that string pending IDs caused.
|
|
164
209
|
if (Orm.instance?.sqlDb && !isStringIdModel(modelName)) {
|
|
165
|
-
rawData.id =
|
|
210
|
+
rawData.id = -(++pendingIdCounter);
|
|
166
211
|
rawData.__pendingSqlId = true;
|
|
167
212
|
return;
|
|
168
213
|
}
|
package/src/mysql/mysql-db.ts
CHANGED
|
@@ -21,6 +21,7 @@ interface PersistContext {
|
|
|
21
21
|
record?: OrmRecord;
|
|
22
22
|
recordId?: unknown;
|
|
23
23
|
oldState?: Record<string, unknown>;
|
|
24
|
+
rawData?: Record<string, unknown>;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
interface PersistResponse {
|
|
@@ -420,8 +421,10 @@ export default class MysqlDB {
|
|
|
420
421
|
|
|
421
422
|
const insertData = this._recordToRow(record, schema);
|
|
422
423
|
|
|
423
|
-
// For auto-increment models, remove the pending ID
|
|
424
|
-
|
|
424
|
+
// For auto-increment models, remove the pending ID.
|
|
425
|
+
// Check context.rawData (not record.__data) because __pendingSqlId is not a model
|
|
426
|
+
// attribute and gets lost during serialization.
|
|
427
|
+
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
425
428
|
|
|
426
429
|
if (isPendingId) {
|
|
427
430
|
delete insertData.id;
|
|
@@ -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, ...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
|
|
@@ -494,8 +494,10 @@ export default class PostgresDB {
|
|
|
494
494
|
|
|
495
495
|
const insertData = this._recordToRow(record, schema, context.rawData);
|
|
496
496
|
|
|
497
|
-
// For auto-increment models, remove the pending ID
|
|
498
|
-
|
|
497
|
+
// For auto-increment models, remove the pending ID.
|
|
498
|
+
// Check context.rawData (not record.__data) because __pendingSqlId is not a model
|
|
499
|
+
// attribute and gets lost during serialization.
|
|
500
|
+
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
499
501
|
|
|
500
502
|
if (isPendingId) {
|
|
501
503
|
delete insertData.id;
|
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'];
|
package/src/serializer.ts
CHANGED
|
@@ -94,8 +94,45 @@ export default class Serializer {
|
|
|
94
94
|
const handlerOptions = { ...options, _relationshipKey: key };
|
|
95
95
|
const childRecord = handler(record, data, handlerOptions);
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// hasMany relationships use a getter so format()/toJSON() always read
|
|
98
|
+
// the live registry array instead of a stale snapshot captured at
|
|
99
|
+
// serialization time. This is critical when child records are created
|
|
100
|
+
// in a later async frame — the belongsTo inverse wiring pushes into
|
|
101
|
+
// the shared registry array, and the getter ensures the parent sees it.
|
|
102
|
+
const isHasMany = (handler as { __relationshipType?: string }).__relationshipType === 'hasMany';
|
|
103
|
+
|
|
104
|
+
if (isHasMany) {
|
|
105
|
+
// `childRecord` IS the shared registry array — define a getter that
|
|
106
|
+
// always dereferences through the same array reference.
|
|
107
|
+
const registryArray = childRecord as unknown[];
|
|
108
|
+
Object.defineProperty(rec, key, {
|
|
109
|
+
enumerable: true,
|
|
110
|
+
configurable: true,
|
|
111
|
+
get: () => registryArray,
|
|
112
|
+
set(v: unknown) { relatedRecords[key] = v; }
|
|
113
|
+
});
|
|
114
|
+
Object.defineProperty(relatedRecords, key, {
|
|
115
|
+
enumerable: true,
|
|
116
|
+
configurable: true,
|
|
117
|
+
get: () => registryArray,
|
|
118
|
+
set(v: unknown) { Object.defineProperty(relatedRecords, key, { value: v, writable: true, enumerable: true, configurable: true }); }
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
rec[key] = childRecord;
|
|
122
|
+
relatedRecords[key] = childRecord;
|
|
123
|
+
|
|
124
|
+
// Preserve the raw FK value in __data when the belongsTo handler
|
|
125
|
+
// couldn't resolve the target (e.g., memory:false model not loaded).
|
|
126
|
+
// This allows adapters to read the FK from __data as a fallback
|
|
127
|
+
// when __relationships[key] is null. Only store when `data` is a
|
|
128
|
+
// truthy non-object — i.e., a raw FK string/number that the handler
|
|
129
|
+
// attempted but failed to resolve. When `data` is null/undefined
|
|
130
|
+
// (optional empty relationship) we intentionally skip to preserve
|
|
131
|
+
// the existing behavior of not populating __data for empty FKs.
|
|
132
|
+
if (childRecord === null && data && typeof data !== 'object') {
|
|
133
|
+
parsedData[key] = data;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
99
136
|
|
|
100
137
|
continue;
|
|
101
138
|
}
|
package/src/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Orm, { relationships } from '@stonyx/orm';
|
|
2
|
-
import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry } from './relationships.js';
|
|
2
|
+
import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry, getPendingBelongsToRegistry } from './relationships.js';
|
|
3
3
|
import ViewResolver from './view-resolver.js';
|
|
4
4
|
|
|
5
5
|
interface UnloadOptions {
|
|
@@ -179,7 +179,12 @@ export default class Store {
|
|
|
179
179
|
// Auto-persist delete to SQL
|
|
180
180
|
if (id && Orm.instance?.sqlDb) {
|
|
181
181
|
Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err: unknown) => {
|
|
182
|
-
|
|
182
|
+
Orm.instance.emitPersistError({
|
|
183
|
+
operation: 'delete',
|
|
184
|
+
modelName: key,
|
|
185
|
+
recordId: id,
|
|
186
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
187
|
+
});
|
|
183
188
|
});
|
|
184
189
|
}
|
|
185
190
|
|
|
@@ -188,6 +193,44 @@ export default class Store {
|
|
|
188
193
|
this.unloadAllRecords(key);
|
|
189
194
|
}
|
|
190
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Evict a record from the store with full relationship registry cleanup,
|
|
198
|
+
* WITHOUT calling record.clean(). This preserves the caller's reference
|
|
199
|
+
* to the returned record (used by memory:false post-persist eviction).
|
|
200
|
+
*
|
|
201
|
+
* @param registryId - The ID used when the record's relationships were
|
|
202
|
+
* registered. For SQL models with pending IDs, this is the original
|
|
203
|
+
* negative pending ID (before the adapter re-keyed to the real DB ID).
|
|
204
|
+
*/
|
|
205
|
+
evictRecord(modelName: string, id: unknown, registryId?: unknown): void {
|
|
206
|
+
const modelStore = this.data.get(modelName);
|
|
207
|
+
if (!modelStore) return;
|
|
208
|
+
|
|
209
|
+
if (typeof id !== 'string' && typeof id !== 'number') return;
|
|
210
|
+
const raw = modelStore.get(id);
|
|
211
|
+
if (!raw || !isStoreRecord(raw)) return;
|
|
212
|
+
|
|
213
|
+
const visited = new Set([`${modelName}:${id}`]);
|
|
214
|
+
|
|
215
|
+
// Remove from hasMany arrays and nullify belongsTo references using current ID
|
|
216
|
+
// (the adapter updates record.id, so value-based matches need the current ID)
|
|
217
|
+
this._removeFromHasManyArrays(modelName, id, visited);
|
|
218
|
+
this._nullifyBelongsToReferences(modelName, id, visited);
|
|
219
|
+
|
|
220
|
+
// Clean up relationship registry entries using the registry key
|
|
221
|
+
// (belongsTo/hasMany registries were keyed by the ID at registration time,
|
|
222
|
+
// which may differ from the current ID if SQL persist re-keyed the record)
|
|
223
|
+
const cleanupId = registryId ?? id;
|
|
224
|
+
this._cleanupRelationshipRegistries(modelName, cleanupId);
|
|
225
|
+
|
|
226
|
+
// If registryId differs from id, also clean with current id as safety net
|
|
227
|
+
if (registryId !== undefined && registryId !== id) {
|
|
228
|
+
this._cleanupRelationshipRegistries(modelName, id);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
modelStore.delete(id);
|
|
232
|
+
}
|
|
233
|
+
|
|
191
234
|
unloadRecord(model: string, id: unknown, options: UnloadOptions = {}): void {
|
|
192
235
|
const modelStore = this.data.get(model);
|
|
193
236
|
|
|
@@ -303,6 +346,30 @@ export default class Store {
|
|
|
303
346
|
|
|
304
347
|
const pendingMap = getPendingRegistry().get(modelName);
|
|
305
348
|
if (pendingMap) pendingMap.delete(recordId);
|
|
349
|
+
|
|
350
|
+
// Clean pendingBelongsTo entries in both directions
|
|
351
|
+
const pendingBelongsToMap = getPendingBelongsToRegistry();
|
|
352
|
+
if (pendingBelongsToMap) {
|
|
353
|
+
// Direction 1: evicted record was the TARGET others were waiting for
|
|
354
|
+
const targetEntries = pendingBelongsToMap.get(modelName);
|
|
355
|
+
if (targetEntries) targetEntries.delete(recordId);
|
|
356
|
+
|
|
357
|
+
// Direction 2: evicted record was the SOURCE with unresolved forward-references
|
|
358
|
+
for (const [, targetIdMap] of pendingBelongsToMap) {
|
|
359
|
+
for (const [targetId, entries] of targetIdMap) {
|
|
360
|
+
if (!Array.isArray(entries)) continue;
|
|
361
|
+
const filtered = entries.filter((e: unknown) => {
|
|
362
|
+
const entry = e as { sourceModelName?: string; relationshipId?: unknown };
|
|
363
|
+
return !(entry.sourceModelName === modelName && entry.relationshipId === recordId);
|
|
364
|
+
});
|
|
365
|
+
if (filtered.length === 0) {
|
|
366
|
+
targetIdMap.delete(targetId);
|
|
367
|
+
} else if (filtered.length < entries.length) {
|
|
368
|
+
targetIdMap.set(targetId, filtered);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
306
373
|
}
|
|
307
374
|
|
|
308
375
|
/**
|