@stonyx/orm 0.3.2-alpha.60 → 0.3.2-alpha.61

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.
@@ -153,17 +153,52 @@ export function updateRecord(record, rawData, userOptions = {}) {
153
153
  }
154
154
  }
155
155
  /**
156
- * gets the next available id based on last record entry.
156
+ * gets the next available id, based on the HIGHEST id present.
157
157
  *
158
158
  * In MySQL mode with numeric IDs, assigns a temporary pending ID.
159
159
  * MySQL's AUTO_INCREMENT provides the real ID after INSERT.
160
+ *
161
+ * ---------------------------------------------------------------------------
162
+ * WAS: `Array.from(storeMap.values()).at(-1).id + 1` — the LAST INSERTED id,
163
+ * not the maximum (abofs/stonyx-orm#203). The store is a Map, so insertion
164
+ * order stops being ascending the moment a record is deleted and recreated, a
165
+ * db.json is written out of order, a directory-mode store is read back in file
166
+ * order, or a caller POSTs a high id and then a low one. After that, every
167
+ * server-assigned id is one that is ALREADY TAKEN — and `createRecord`'s
168
+ * last-entry-wins branch then overwrites that record IN PLACE and answers 200.
169
+ * No error, no 409, and the store's size does not change. That is the whole
170
+ * defect, and it is reachable from a create with NO id at all, which is the
171
+ * most ordinary write a consumer performs.
172
+ *
173
+ * Covered by test/unit/assign-record-id-test.ts. Note for anyone changing this
174
+ * function: before that file existed, the whole suite scored 951/0 both on the
175
+ * defect and on a naive `Math.max` fix that introduced a second one. A green
176
+ * suite is not evidence here; those assertions are.
177
+ * ---------------------------------------------------------------------------
160
178
  */
161
179
  function assignRecordId(modelName, rawData) {
162
- if (rawData.id)
180
+ // PRESENCE, not truthiness. `0` is a legal value for an `attr('number')` id,
181
+ // and `if (rawData.id) return` silently reassigned it, handing the caller back
182
+ // a different record than the one it named (#203).
183
+ //
184
+ // `''` is deliberately NOT honoured here and this is not an oversight: it is
185
+ // the one string that means "no id". `parseInt('')` is `NaN`, a record CAN be
186
+ // held under the key `NaN`, and orm-request.ts's body-id normalisation relies
187
+ // on `''` staying absent — otherwise `POST {"id":""}` answers 409 against a
188
+ // record it never named. Pinned by test/unit/assign-record-id-test.ts (AC6's
189
+ // BOUNDARY assertions) and by access-filter-enforcement-test.ts assertion 44.
190
+ // Widening this to `!== undefined` breaks both.
191
+ if (rawData.id || rawData.id === 0)
163
192
  return;
164
193
  // In SQL mode with numeric IDs, defer to database auto-increment.
165
194
  // Use unique negative integers — they survive the number transform (parseInt preserves negatives)
166
195
  // and avoid NaN store-key collisions that string pending IDs caused.
196
+ //
197
+ // This early return is ABOVE the max computation on purpose: a pending
198
+ // negative must never be a candidate for, or be perturbed by, the max path.
199
+ // Pinned directly (AC5.3) rather than by asserting the max is unaffected —
200
+ // that assertion could not have failed, because nothing negative ever reaches
201
+ // the code below.
167
202
  if (Orm.instance?.sqlDb && !isStringIdModel(modelName)) {
168
203
  rawData.id = -(++pendingIdCounter);
169
204
  rawData.__pendingSqlId = true;
@@ -173,13 +208,73 @@ function assignRecordId(modelName, rawData) {
173
208
  if (!storeMap)
174
209
  throw new Error(`Cannot assign record ID: model "${modelName}" not found in store`);
175
210
  const modelStore = Array.from(storeMap.values()).filter(isOrmRecord);
176
- const lastRecord = modelStore.at(-1);
177
- rawData.id = lastRecord ? lastRecord.id + 1 : 1;
211
+ // The shape of src/standalone-db.ts:134-137, and it is chosen over
212
+ // `Math.max(...ids)` for a reason that is measurable rather than stylistic:
213
+ // a store CAN hold a record under the key `NaN` (`{id: ' '}` is truthy, so
214
+ // it survives the guard above and NaNs in the number transform — that is the
215
+ // state access-filter-enforcement-test.ts assertion 44 constructs). `Math.max`
216
+ // returns `NaN` if any operand is `NaN`, so it would assign `NaN`, land on
217
+ // that slot and overwrite it — exactly the defect being fixed, in a new
218
+ // disguise. This reduce cannot: non-numbers are skipped, and `NaN > max` is
219
+ // `false`. Pinned by AC2.
220
+ const maxId = modelStore.reduce((max, record) => {
221
+ const recordId = record.id;
222
+ return typeof recordId === 'number' && recordId > max ? recordId : max;
223
+ }, 0);
224
+ // THE OCCUPANCY CHECK RUNS ON THE LANDING KEY, NOT ON THE RAW CANDIDATE, and
225
+ // the difference is a silent data loss rather than a nicety.
226
+ //
227
+ // `createRecord` looks the record up under `rawData.id` (:50) but WRITES it
228
+ // under `record.id` (:69) — the value after the model's declared id transform
229
+ // has run inside `serialize`. On a string-id model those two differ: the
230
+ // number `1` is looked up, the record lands under the string `'1'`. A guard
231
+ // written as `storeMap.has(rawData.id)` therefore checks a key the record will
232
+ // never occupy, misses an occupied slot and overwrites it — measured: owner
233
+ // '1' age 55 -> 9, store size unchanged, no error. That is abofs/stonyx-orm
234
+ // #205's lookup-key/landing-key divergence reappearing inside #203's own fix,
235
+ // which is why AC4 exists and why `rawData.id` is set to the LANDING key
236
+ // below: it makes :50 and :69 agree by construction.
237
+ //
238
+ // Termination: with an injective id transform at most `storeMap.size`
239
+ // candidates can be occupied. A NON-injective id type would otherwise spin
240
+ // forever, so the loop is bounded and exits with a defined error the route can
241
+ // report instead of hanging the request.
242
+ //
243
+ // Resolved ONCE, outside the loop: `getIdType` instantiates the model class,
244
+ // so resolving it per candidate would put a model construction on every
245
+ // iteration of a loop that exists to walk past occupied slots.
246
+ const toStoreKey = storeKeyDeriver(modelName);
247
+ let candidate = maxId + 1;
248
+ let landingKey = toStoreKey(candidate);
249
+ let attempts = 0;
250
+ while (storeMap.has(landingKey)) {
251
+ if (++attempts > storeMap.size) {
252
+ throw new Error(`Cannot assign record ID: no free id available for model "${modelName}"`);
253
+ }
254
+ candidate += 1;
255
+ landingKey = toStoreKey(candidate);
256
+ }
257
+ rawData.id = landingKey;
178
258
  }
179
- function isStringIdModel(modelName) {
180
- const modelClass = Orm.instance.getRecordClasses(modelName).modelClass;
259
+ /**
260
+ * Returns the derivation that maps an id VALUE to the store KEY a record
261
+ * carrying it will actually be filed under — the model's declared id transform,
262
+ * the same one `serialize` runs at createRecord:68 before the `.set` at :69.
263
+ */
264
+ function storeKeyDeriver(modelName) {
265
+ const idType = getIdType(modelName);
266
+ const transform = idType ? Orm.instance?.transforms?.[idType] : undefined;
267
+ if (typeof transform !== 'function')
268
+ return value => value;
269
+ return value => transform(value);
270
+ }
271
+ function getIdType(modelName) {
272
+ const modelClass = Orm.instance?.getRecordClasses(modelName).modelClass;
181
273
  if (!modelClass)
182
- return false;
274
+ return undefined;
183
275
  const model = new modelClass(modelName);
184
- return model.id?.type === 'string';
276
+ return model.id?.type;
277
+ }
278
+ function isStringIdModel(modelName) {
279
+ return getIdType(modelName) === 'string';
185
280
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.60",
7
+ "version": "0.3.2-alpha.61",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
63
63
  "dependencies": {
64
- "@stonyx/cron": "0.2.1-beta.84",
64
+ "@stonyx/cron": "0.2.1-beta.85",
65
65
  "@stonyx/events": "0.1.1-beta.52",
66
66
  "@stonyx/utils": "0.2.3-beta.26",
67
67
  "stonyx": "0.2.3-beta.77"
@@ -91,7 +91,7 @@
91
91
  }
92
92
  },
93
93
  "devDependencies": {
94
- "@stonyx/rest-server": "0.2.1-beta.83",
94
+ "@stonyx/rest-server": "0.2.1-beta.84",
95
95
  "@types/node": "^25.6.0",
96
96
  "mysql2": "^3.20.0",
97
97
  "pg": "^8.20.0",
@@ -195,17 +195,52 @@ export function updateRecord(record: OrmRecord, rawData: unknown, userOptions: C
195
195
  }
196
196
 
197
197
  /**
198
- * gets the next available id based on last record entry.
198
+ * gets the next available id, based on the HIGHEST id present.
199
199
  *
200
200
  * In MySQL mode with numeric IDs, assigns a temporary pending ID.
201
201
  * MySQL's AUTO_INCREMENT provides the real ID after INSERT.
202
+ *
203
+ * ---------------------------------------------------------------------------
204
+ * WAS: `Array.from(storeMap.values()).at(-1).id + 1` — the LAST INSERTED id,
205
+ * not the maximum (abofs/stonyx-orm#203). The store is a Map, so insertion
206
+ * order stops being ascending the moment a record is deleted and recreated, a
207
+ * db.json is written out of order, a directory-mode store is read back in file
208
+ * order, or a caller POSTs a high id and then a low one. After that, every
209
+ * server-assigned id is one that is ALREADY TAKEN — and `createRecord`'s
210
+ * last-entry-wins branch then overwrites that record IN PLACE and answers 200.
211
+ * No error, no 409, and the store's size does not change. That is the whole
212
+ * defect, and it is reachable from a create with NO id at all, which is the
213
+ * most ordinary write a consumer performs.
214
+ *
215
+ * Covered by test/unit/assign-record-id-test.ts. Note for anyone changing this
216
+ * function: before that file existed, the whole suite scored 951/0 both on the
217
+ * defect and on a naive `Math.max` fix that introduced a second one. A green
218
+ * suite is not evidence here; those assertions are.
219
+ * ---------------------------------------------------------------------------
202
220
  */
203
221
  function assignRecordId(modelName: string, rawData: { [key: string]: unknown }): void {
204
- if (rawData.id) return;
222
+ // PRESENCE, not truthiness. `0` is a legal value for an `attr('number')` id,
223
+ // and `if (rawData.id) return` silently reassigned it, handing the caller back
224
+ // a different record than the one it named (#203).
225
+ //
226
+ // `''` is deliberately NOT honoured here and this is not an oversight: it is
227
+ // the one string that means "no id". `parseInt('')` is `NaN`, a record CAN be
228
+ // held under the key `NaN`, and orm-request.ts's body-id normalisation relies
229
+ // on `''` staying absent — otherwise `POST {"id":""}` answers 409 against a
230
+ // record it never named. Pinned by test/unit/assign-record-id-test.ts (AC6's
231
+ // BOUNDARY assertions) and by access-filter-enforcement-test.ts assertion 44.
232
+ // Widening this to `!== undefined` breaks both.
233
+ if (rawData.id || rawData.id === 0) return;
205
234
 
206
235
  // In SQL mode with numeric IDs, defer to database auto-increment.
207
236
  // Use unique negative integers — they survive the number transform (parseInt preserves negatives)
208
237
  // and avoid NaN store-key collisions that string pending IDs caused.
238
+ //
239
+ // This early return is ABOVE the max computation on purpose: a pending
240
+ // negative must never be a candidate for, or be perturbed by, the max path.
241
+ // Pinned directly (AC5.3) rather than by asserting the max is unaffected —
242
+ // that assertion could not have failed, because nothing negative ever reaches
243
+ // the code below.
209
244
  if (Orm.instance?.sqlDb && !isStringIdModel(modelName)) {
210
245
  rawData.id = -(++pendingIdCounter);
211
246
  rawData.__pendingSqlId = true;
@@ -215,15 +250,85 @@ function assignRecordId(modelName: string, rawData: { [key: string]: unknown }):
215
250
  const storeMap = store.get(modelName);
216
251
  if (!storeMap) throw new Error(`Cannot assign record ID: model "${modelName}" not found in store`);
217
252
  const modelStore = Array.from(storeMap.values()).filter(isOrmRecord);
218
- const lastRecord = modelStore.at(-1);
219
- rawData.id = lastRecord ? (lastRecord.id as number) + 1 : 1;
253
+
254
+ // The shape of src/standalone-db.ts:134-137, and it is chosen over
255
+ // `Math.max(...ids)` for a reason that is measurable rather than stylistic:
256
+ // a store CAN hold a record under the key `NaN` (`{id: ' '}` is truthy, so
257
+ // it survives the guard above and NaNs in the number transform — that is the
258
+ // state access-filter-enforcement-test.ts assertion 44 constructs). `Math.max`
259
+ // returns `NaN` if any operand is `NaN`, so it would assign `NaN`, land on
260
+ // that slot and overwrite it — exactly the defect being fixed, in a new
261
+ // disguise. This reduce cannot: non-numbers are skipped, and `NaN > max` is
262
+ // `false`. Pinned by AC2.
263
+ const maxId = modelStore.reduce((max: number, record) => {
264
+ const recordId = record.id as unknown;
265
+
266
+ return typeof recordId === 'number' && recordId > max ? recordId : max;
267
+ }, 0);
268
+
269
+ // THE OCCUPANCY CHECK RUNS ON THE LANDING KEY, NOT ON THE RAW CANDIDATE, and
270
+ // the difference is a silent data loss rather than a nicety.
271
+ //
272
+ // `createRecord` looks the record up under `rawData.id` (:50) but WRITES it
273
+ // under `record.id` (:69) — the value after the model's declared id transform
274
+ // has run inside `serialize`. On a string-id model those two differ: the
275
+ // number `1` is looked up, the record lands under the string `'1'`. A guard
276
+ // written as `storeMap.has(rawData.id)` therefore checks a key the record will
277
+ // never occupy, misses an occupied slot and overwrites it — measured: owner
278
+ // '1' age 55 -> 9, store size unchanged, no error. That is abofs/stonyx-orm
279
+ // #205's lookup-key/landing-key divergence reappearing inside #203's own fix,
280
+ // which is why AC4 exists and why `rawData.id` is set to the LANDING key
281
+ // below: it makes :50 and :69 agree by construction.
282
+ //
283
+ // Termination: with an injective id transform at most `storeMap.size`
284
+ // candidates can be occupied. A NON-injective id type would otherwise spin
285
+ // forever, so the loop is bounded and exits with a defined error the route can
286
+ // report instead of hanging the request.
287
+ //
288
+ // Resolved ONCE, outside the loop: `getIdType` instantiates the model class,
289
+ // so resolving it per candidate would put a model construction on every
290
+ // iteration of a loop that exists to walk past occupied slots.
291
+ const toStoreKey = storeKeyDeriver(modelName);
292
+
293
+ let candidate = maxId + 1;
294
+ let landingKey = toStoreKey(candidate);
295
+ let attempts = 0;
296
+
297
+ while (storeMap.has(landingKey)) {
298
+ if (++attempts > storeMap.size) {
299
+ throw new Error(`Cannot assign record ID: no free id available for model "${modelName}"`);
300
+ }
301
+
302
+ candidate += 1;
303
+ landingKey = toStoreKey(candidate);
304
+ }
305
+
306
+ rawData.id = landingKey;
220
307
  }
221
308
 
222
- function isStringIdModel(modelName: string): boolean {
223
- const modelClass = Orm.instance.getRecordClasses(modelName).modelClass as (new (name: string) => { [key: string]: unknown }) | undefined;
224
- if (!modelClass) return false;
309
+ /**
310
+ * Returns the derivation that maps an id VALUE to the store KEY a record
311
+ * carrying it will actually be filed under — the model's declared id transform,
312
+ * the same one `serialize` runs at createRecord:68 before the `.set` at :69.
313
+ */
314
+ function storeKeyDeriver(modelName: string): (value: number) => number | string {
315
+ const idType = getIdType(modelName);
316
+ const transform = idType ? Orm.instance?.transforms?.[idType] : undefined;
317
+
318
+ if (typeof transform !== 'function') return value => value;
319
+
320
+ return value => transform(value) as number | string;
321
+ }
322
+
323
+ function getIdType(modelName: string): string | undefined {
324
+ const modelClass = Orm.instance?.getRecordClasses(modelName).modelClass as (new (name: string) => { [key: string]: unknown }) | undefined;
325
+ if (!modelClass) return undefined;
225
326
 
226
327
  const model = new modelClass(modelName);
227
328
 
228
- return (model.id as { type?: string } | undefined)?.type === 'string';
329
+ return (model.id as { type?: string } | undefined)?.type;
330
+ }
331
+
332
+ function isStringIdModel(modelName: string): boolean {
333
+ return getIdType(modelName) === 'string';
229
334
  }