@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.
@@ -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 numeric-ID models with a pending ID, generate a ULID
329
+ // For models with a pending ID, generate a unique replacement ID
313
330
  let finalId = record.id;
314
331
  if (isPendingId) {
315
- finalId = generateUlid();
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;
@@ -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 hasManyReg = getHasManyRegistry();
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 = hasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
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
- const ms = store.get(modelName);
100
- if (ms)
101
- ms.delete(record.id);
120
+ store.evictRecord(modelName, record.id);
102
121
  }
103
122
  });
104
123
  }
@@ -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
- rec[key] = childRecord;
83
- relatedRecords[key] = childRecord;
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.15",
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.66",
65
- "@stonyx/events": "0.1.1-beta.49",
66
- "stonyx": "0.2.3-beta.66"
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.67",
94
- "@stonyx/utils": "0.2.3-beta.24",
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 numeric-ID models with a pending ID, generate a ULID
480
+ // For models with a pending ID, generate a unique replacement ID
463
481
  let finalId: unknown = record.id;
464
482
  if (isPendingId) {
465
- finalId = generateUlid();
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);
@@ -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 hasManyReg = getHasManyRegistry();
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 = hasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
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
- const ms = store.get(modelName);
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
- rec[key] = childRecord;
98
- relatedRecords[key] = childRecord;
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