@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.
- package/dist/manage-record.js +103 -8
- package/package.json +3 -3
- package/src/manage-record.ts +113 -8
package/dist/manage-record.js
CHANGED
|
@@ -153,17 +153,52 @@ export function updateRecord(record, rawData, userOptions = {}) {
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
/**
|
|
156
|
-
* gets the next available id based on
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
|
274
|
+
return undefined;
|
|
183
275
|
const model = new modelClass(modelName);
|
|
184
|
-
return model.id?.type
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/manage-record.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
329
|
+
return (model.id as { type?: string } | undefined)?.type;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isStringIdModel(modelName: string): boolean {
|
|
333
|
+
return getIdType(modelName) === 'string';
|
|
229
334
|
}
|