@stonyx/orm 0.3.2-alpha.15 → 0.3.2-alpha.17
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/dist/dynamodb/dynamodb-db.js +20 -2
- package/dist/manage-record.js +24 -5
- package/dist/serializer.js +27 -2
- package/dist/store.d.ts +6 -0
- package/dist/store.js +20 -0
- package/package.json +6 -6
- package/src/dynamodb/dynamodb-db.ts +21 -2
- package/src/manage-record.ts +25 -4
- package/src/serializer.ts +27 -2
- package/src/store.ts +21 -0
|
@@ -33,6 +33,23 @@ function generateUlid() {
|
|
|
33
33
|
}
|
|
34
34
|
return id;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Generates a monotonically unique numeric ID for DynamoDB tables with numeric keys.
|
|
38
|
+
* Uses timestamp-based generation with a sub-millisecond counter to ensure uniqueness.
|
|
39
|
+
*/
|
|
40
|
+
let _numericIdCounter = 0;
|
|
41
|
+
let _numericIdLastMs = 0;
|
|
42
|
+
function generateNumericId() {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
if (now === _numericIdLastMs) {
|
|
45
|
+
_numericIdCounter++;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
_numericIdLastMs = now;
|
|
49
|
+
_numericIdCounter = 0;
|
|
50
|
+
}
|
|
51
|
+
return now * 1000 + _numericIdCounter;
|
|
52
|
+
}
|
|
36
53
|
// ---------------------------------------------------------------------------
|
|
37
54
|
// SDK Command factories (injectable for testing without real AWS SDK)
|
|
38
55
|
// ---------------------------------------------------------------------------
|
|
@@ -309,10 +326,11 @@ export default class DynamoDBDB {
|
|
|
309
326
|
return;
|
|
310
327
|
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
311
328
|
const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
|
|
312
|
-
// For
|
|
329
|
+
// For models with a pending ID, generate a unique replacement ID
|
|
313
330
|
let finalId = record.id;
|
|
314
331
|
if (isPendingId) {
|
|
315
|
-
|
|
332
|
+
const keyType = this.deps.getDynamoKeyType(schema.idType);
|
|
333
|
+
finalId = keyType === 'N' ? generateNumericId() : generateUlid();
|
|
316
334
|
}
|
|
317
335
|
const item = this._recordToItem(record, schema, context.rawData);
|
|
318
336
|
item.id = finalId;
|
package/dist/manage-record.js
CHANGED
|
@@ -52,13 +52,34 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
|
|
|
52
52
|
relationship.push(record);
|
|
53
53
|
pendingHasMany.splice(0);
|
|
54
54
|
}
|
|
55
|
+
// FK-based inverse hasMany wiring — when a child record is created with a
|
|
56
|
+
// foreign-key field (e.g. `owner: 'owner-1'` on an animal), find any parent
|
|
57
|
+
// whose hasMany registry targets this model and push the child into the
|
|
58
|
+
// parent's shared array. This covers edge cases where the child is created
|
|
59
|
+
// in a separate async frame without a belongsTo handler firing.
|
|
60
|
+
const hasManyReg = getHasManyRegistry();
|
|
61
|
+
if (hasManyReg) {
|
|
62
|
+
for (const [parentModelName, targetMap] of hasManyReg) {
|
|
63
|
+
const childArrayMap = targetMap.get(modelName);
|
|
64
|
+
if (!childArrayMap)
|
|
65
|
+
continue;
|
|
66
|
+
// Check if rawData contains a FK field matching the parent model name
|
|
67
|
+
const fkValue = rawData[parentModelName];
|
|
68
|
+
if (fkValue === undefined || fkValue === null)
|
|
69
|
+
continue;
|
|
70
|
+
const parentArray = childArrayMap.get(fkValue);
|
|
71
|
+
if (parentArray && !parentArray.includes(record)) {
|
|
72
|
+
parentArray.push(record);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
55
76
|
// Fulfill pending belongsTo relationships
|
|
56
77
|
const pendingBelongsToQueue = getPendingBelongsToRegistry();
|
|
57
78
|
const pendingBelongsToRaw = pendingBelongsToQueue.get(modelName)?.get(record.id);
|
|
58
79
|
const pendingBelongsTo = Array.isArray(pendingBelongsToRaw) ? pendingBelongsToRaw : undefined;
|
|
59
80
|
if (pendingBelongsTo) {
|
|
60
81
|
const belongsToReg = getBelongsToRegistry();
|
|
61
|
-
const
|
|
82
|
+
const pendingHasManyReg = getHasManyRegistry();
|
|
62
83
|
for (const { sourceRecord, sourceModelName, relationshipKey, relationshipId } of pendingBelongsTo) {
|
|
63
84
|
// Update the belongsTo relationship on the source record
|
|
64
85
|
sourceRecord.__relationships[relationshipKey] = record;
|
|
@@ -72,7 +93,7 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
|
|
|
72
93
|
}
|
|
73
94
|
}
|
|
74
95
|
// Wire inverse hasMany if it exists
|
|
75
|
-
const inverseHasMany =
|
|
96
|
+
const inverseHasMany = pendingHasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
|
|
76
97
|
if (inverseHasMany && !inverseHasMany.includes(sourceRecord)) {
|
|
77
98
|
inverseHasMany.push(sourceRecord);
|
|
78
99
|
}
|
|
@@ -96,9 +117,7 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
|
|
|
96
117
|
.finally(() => {
|
|
97
118
|
// Evict non-memory records after persist to prevent unbounded heap growth (stonyx#81)
|
|
98
119
|
if (store._memoryResolver && !store._memoryResolver(modelName)) {
|
|
99
|
-
|
|
100
|
-
if (ms)
|
|
101
|
-
ms.delete(record.id);
|
|
120
|
+
store.evictRecord(modelName, record.id);
|
|
102
121
|
}
|
|
103
122
|
});
|
|
104
123
|
}
|
package/dist/serializer.js
CHANGED
|
@@ -79,8 +79,33 @@ export default class Serializer {
|
|
|
79
79
|
// Pass relationship key name to handler for pending fulfillment
|
|
80
80
|
const handlerOptions = { ...options, _relationshipKey: key };
|
|
81
81
|
const childRecord = handler(record, data, handlerOptions);
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// hasMany relationships use a getter so format()/toJSON() always read
|
|
83
|
+
// the live registry array instead of a stale snapshot captured at
|
|
84
|
+
// serialization time. This is critical when child records are created
|
|
85
|
+
// in a later async frame — the belongsTo inverse wiring pushes into
|
|
86
|
+
// the shared registry array, and the getter ensures the parent sees it.
|
|
87
|
+
const isHasMany = handler.__relationshipType === 'hasMany';
|
|
88
|
+
if (isHasMany) {
|
|
89
|
+
// `childRecord` IS the shared registry array — define a getter that
|
|
90
|
+
// always dereferences through the same array reference.
|
|
91
|
+
const registryArray = childRecord;
|
|
92
|
+
Object.defineProperty(rec, key, {
|
|
93
|
+
enumerable: true,
|
|
94
|
+
configurable: true,
|
|
95
|
+
get: () => registryArray,
|
|
96
|
+
set(v) { relatedRecords[key] = v; }
|
|
97
|
+
});
|
|
98
|
+
Object.defineProperty(relatedRecords, key, {
|
|
99
|
+
enumerable: true,
|
|
100
|
+
configurable: true,
|
|
101
|
+
get: () => registryArray,
|
|
102
|
+
set(v) { Object.defineProperty(relatedRecords, key, { value: v, writable: true, enumerable: true, configurable: true }); }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
rec[key] = childRecord;
|
|
107
|
+
relatedRecords[key] = childRecord;
|
|
108
|
+
}
|
|
84
109
|
continue;
|
|
85
110
|
}
|
|
86
111
|
// Aggregate property handling — use the rawData value, not the aggregate descriptor
|
package/dist/store.d.ts
CHANGED
|
@@ -46,6 +46,12 @@ export default class Store {
|
|
|
46
46
|
private _isMemoryModel;
|
|
47
47
|
set(key: string, value: Map<number | string, unknown>): void;
|
|
48
48
|
remove(key: string, id?: number | string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Evict a record from the store with full relationship registry cleanup,
|
|
51
|
+
* WITHOUT calling record.clean(). This preserves the caller's reference
|
|
52
|
+
* to the returned record (used by memory:false post-persist eviction).
|
|
53
|
+
*/
|
|
54
|
+
evictRecord(modelName: string, id: unknown): void;
|
|
49
55
|
unloadRecord(model: string, id: unknown, options?: UnloadOptions): void;
|
|
50
56
|
unloadAllRecords(model: string, options?: UnloadOptions): void;
|
|
51
57
|
private _removeFromHasManyArrays;
|
package/dist/store.js
CHANGED
|
@@ -127,6 +127,26 @@ export default class Store {
|
|
|
127
127
|
return this.unloadRecord(key, id);
|
|
128
128
|
this.unloadAllRecords(key);
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Evict a record from the store with full relationship registry cleanup,
|
|
132
|
+
* WITHOUT calling record.clean(). This preserves the caller's reference
|
|
133
|
+
* to the returned record (used by memory:false post-persist eviction).
|
|
134
|
+
*/
|
|
135
|
+
evictRecord(modelName, id) {
|
|
136
|
+
const modelStore = this.data.get(modelName);
|
|
137
|
+
if (!modelStore)
|
|
138
|
+
return;
|
|
139
|
+
if (typeof id !== 'string' && typeof id !== 'number')
|
|
140
|
+
return;
|
|
141
|
+
const raw = modelStore.get(id);
|
|
142
|
+
if (!raw || !isStoreRecord(raw))
|
|
143
|
+
return;
|
|
144
|
+
const visited = new Set([`${modelName}:${id}`]);
|
|
145
|
+
this._removeFromHasManyArrays(modelName, id, visited);
|
|
146
|
+
this._nullifyBelongsToReferences(modelName, id, visited);
|
|
147
|
+
this._cleanupRelationshipRegistries(modelName, id);
|
|
148
|
+
modelStore.delete(id);
|
|
149
|
+
}
|
|
130
150
|
unloadRecord(model, id, options = {}) {
|
|
131
151
|
const modelStore = this.data.get(model);
|
|
132
152
|
if (!modelStore) {
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.3.2-alpha.
|
|
7
|
+
"version": "0.3.2-alpha.17",
|
|
8
8
|
"description": "",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"type": "module",
|
|
@@ -61,9 +61,9 @@
|
|
|
61
61
|
},
|
|
62
62
|
"homepage": "https://github.com/abofs/stonyx-orm#readme",
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@stonyx/cron": "0.2.1-beta.
|
|
65
|
-
"@stonyx/events": "0.1.1-beta.
|
|
66
|
-
"stonyx": "0.2.3-beta.
|
|
64
|
+
"@stonyx/cron": "0.2.1-beta.70",
|
|
65
|
+
"@stonyx/events": "0.1.1-beta.51",
|
|
66
|
+
"stonyx": "0.2.3-beta.68"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"@aws-sdk/client-dynamodb": "^3.0.0",
|
|
@@ -90,8 +90,8 @@
|
|
|
90
90
|
}
|
|
91
91
|
},
|
|
92
92
|
"devDependencies": {
|
|
93
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
94
|
-
"@stonyx/utils": "0.2.3-beta.
|
|
93
|
+
"@stonyx/rest-server": "0.2.1-beta.71",
|
|
94
|
+
"@stonyx/utils": "0.2.3-beta.25",
|
|
95
95
|
"@types/node": "^25.6.0",
|
|
96
96
|
"mysql2": "^3.20.0",
|
|
97
97
|
"pg": "^8.20.0",
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -459,10 +477,11 @@ export default class DynamoDBDB {
|
|
|
459
477
|
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
460
478
|
const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
|
|
461
479
|
|
|
462
|
-
// For
|
|
480
|
+
// For models with a pending ID, generate a unique replacement ID
|
|
463
481
|
let finalId: unknown = record.id;
|
|
464
482
|
if (isPendingId) {
|
|
465
|
-
|
|
483
|
+
const keyType = this.deps.getDynamoKeyType(schema.idType);
|
|
484
|
+
finalId = keyType === 'N' ? generateNumericId() : generateUlid();
|
|
466
485
|
}
|
|
467
486
|
|
|
468
487
|
const item = this._recordToItem(record, schema, context.rawData);
|
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);
|
|
@@ -130,8 +152,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
|
|
|
130
152
|
.finally(() => {
|
|
131
153
|
// Evict non-memory records after persist to prevent unbounded heap growth (stonyx#81)
|
|
132
154
|
if (store._memoryResolver && !store._memoryResolver(modelName)) {
|
|
133
|
-
|
|
134
|
-
if (ms) ms.delete(record.id as number | string);
|
|
155
|
+
store.evictRecord(modelName, record.id);
|
|
135
156
|
}
|
|
136
157
|
});
|
|
137
158
|
}
|
package/src/serializer.ts
CHANGED
|
@@ -94,8 +94,33 @@ 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
|
+
}
|
|
99
124
|
|
|
100
125
|
continue;
|
|
101
126
|
}
|
package/src/store.ts
CHANGED
|
@@ -193,6 +193,27 @@ export default class Store {
|
|
|
193
193
|
this.unloadAllRecords(key);
|
|
194
194
|
}
|
|
195
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
|
+
evictRecord(modelName: string, id: unknown): void {
|
|
202
|
+
const modelStore = this.data.get(modelName);
|
|
203
|
+
if (!modelStore) return;
|
|
204
|
+
|
|
205
|
+
if (typeof id !== 'string' && typeof id !== 'number') return;
|
|
206
|
+
const raw = modelStore.get(id);
|
|
207
|
+
if (!raw || !isStoreRecord(raw)) return;
|
|
208
|
+
|
|
209
|
+
const visited = new Set([`${modelName}:${id}`]);
|
|
210
|
+
this._removeFromHasManyArrays(modelName, id, visited);
|
|
211
|
+
this._nullifyBelongsToReferences(modelName, id, visited);
|
|
212
|
+
this._cleanupRelationshipRegistries(modelName, id);
|
|
213
|
+
|
|
214
|
+
modelStore.delete(id);
|
|
215
|
+
}
|
|
216
|
+
|
|
196
217
|
unloadRecord(model: string, id: unknown, options: UnloadOptions = {}): void {
|
|
197
218
|
const modelStore = this.data.get(model);
|
|
198
219
|
|