uql-orm 0.76.0 → 0.77.0
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.
|
@@ -87,8 +87,14 @@ export declare abstract class AbstractQuerier implements Querier {
|
|
|
87
87
|
/** Settles the rows first where the update cascades, so a payload changing what `$where` reads still names them. */
|
|
88
88
|
updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdateWrite<E>, opts?: QueryOptions): Promise<number>;
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
90
|
+
* The write every update runs, matching the version `lockKey` names where one is being held. Only a
|
|
91
|
+
* restore passes none: it writes no content, so there is no update of anyone's to lose.
|
|
92
|
+
*/
|
|
93
|
+
private updateRows;
|
|
94
|
+
/**
|
|
95
|
+
* Why an update matched no row. The filter named the row by its id, so reading by that id alone
|
|
96
|
+
* separates the three: the row is gone, another writer moved the version on, or the rest of the
|
|
97
|
+
* filter excluded a row still at that version. One read, only on the failure, so the happy path
|
|
92
98
|
* still costs one statement. Best effort by nature - the row can change again while we ask.
|
|
93
99
|
*/
|
|
94
100
|
private throwStaleVersion;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assertSoleId, getMeta, idOf, namesKey, relationOf } from '../entity/index.js';
|
|
2
2
|
import { cascadesOnDelete, childrenOf, clone, entityName, fillOnFields, filterFieldKeys, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isPagedQuery, hasKeys, isScalarId, LoggerWrapper, parentJoins, queryLoggerFor, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, someKey, targetKeyColumns, whereIds, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
3
|
-
import { enrichError, UqlOptimisticLockError } from './queryError.js';
|
|
3
|
+
import { enrichError, UqlLockUsageError, UqlOptimisticLockError } from './queryError.js';
|
|
4
4
|
/**
|
|
5
5
|
* Refuses a nullish id, which would reduce to no filter at all, and a composite id missing a column,
|
|
6
6
|
* which would address every row agreeing on the rest. Callers are `async`, so it always rejects.
|
|
@@ -39,26 +39,41 @@ function assertNamesRows(entity, method, q, opts) {
|
|
|
39
39
|
throw new TypeError(`'${method}' over '${entity.name}' names no rows, so it would address every one: pass '{ unfiltered: true }' to mean it`);
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
|
-
* An optimistic lock as one update applies it: the
|
|
43
|
-
*
|
|
44
|
-
* arithmetic, since
|
|
42
|
+
* An optimistic lock as one update applies it: the version the payload carried, the one that replaces
|
|
43
|
+
* it, and the filter pinning what the column still holds. The bump is a plain value rather than SQL
|
|
44
|
+
* arithmetic, since that filter already pins it, which spares every engine a read-back.
|
|
45
45
|
*/
|
|
46
46
|
function lockVersion(meta, key, q, row) {
|
|
47
47
|
const expected = row[key];
|
|
48
|
-
if (expected
|
|
49
|
-
throw new
|
|
48
|
+
if (typeof expected !== 'number' && typeof expected !== 'bigint') {
|
|
49
|
+
throw new UqlLockUsageError(`an update of '${entityName(meta)}' carries no '${key}': a versioned row is written against the version it was read at`);
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
const next = typeof expected === 'bigint' ? expected + 1n : expected + 1;
|
|
52
|
+
// Spread, as every other added predicate here is: one flat `AND`, and a caller already filtering on
|
|
53
|
+
// the version contradicts itself into matching nothing, which is what they asked for.
|
|
54
|
+
return { expected, next, q: { ...q, $where: { ...q.$where, [key]: expected } } };
|
|
53
55
|
}
|
|
54
56
|
/**
|
|
55
57
|
* Refuses a write that cannot carry the lock, rather than writing over whatever the row holds now.
|
|
56
58
|
* An upsert has no portable way to match a version - MySQL's `ON DUPLICATE KEY UPDATE` takes no
|
|
57
59
|
* `WHERE` - and a write the library itself composes has no version to carry.
|
|
58
60
|
*/
|
|
59
|
-
function assertUnversioned(meta,
|
|
61
|
+
function assertUnversioned(meta, what) {
|
|
60
62
|
if (meta.version) {
|
|
61
|
-
throw new
|
|
63
|
+
throw new UqlLockUsageError(`cannot ${what} the versioned '${entityName(meta)}': it carries no '${meta.version}' to match, so update it by id`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* What a versioned update has to be for its lock to hold: one row, named by its id, written by one
|
|
68
|
+
* statement. A filter naming more than one row cannot say which of them the payload's single version
|
|
69
|
+
* belongs to, and anything settled first - a page, a relation write, a filter an engine cannot read in
|
|
70
|
+
* an `UPDATE` - reads the ids and writes them separately, putting the race back in the gap between.
|
|
71
|
+
*/
|
|
72
|
+
function assertLockableUpdate(meta, q, settles) {
|
|
73
|
+
const where = q.$where;
|
|
74
|
+
const namesOneRow = meta.ids.every((key) => where?.[key] !== undefined && isScalarId(where[key]));
|
|
75
|
+
if (!namesOneRow || settles) {
|
|
76
|
+
throw new UqlLockUsageError(`cannot update '${entityName(meta)}' this way: a versioned row is matched and written in one statement, so it is named by its ${meta.ids.map((id) => `'${id}'`).join(', ')}, takes no '$sort', '$limit' or '$skip', writes no relation, and filters by none`);
|
|
62
77
|
}
|
|
63
78
|
}
|
|
64
79
|
/**
|
|
@@ -218,45 +233,55 @@ export class AbstractQuerier {
|
|
|
218
233
|
/** Settles the rows first where the update cascades, so a payload changing what `$where` reads still names them. */
|
|
219
234
|
async updateMany(entity, q, payload, opts) {
|
|
220
235
|
assertNamesRows(entity, 'updateMany', q, opts);
|
|
236
|
+
return this.hooked(entity, 'Update', [payload], ([row]) => this.updateRows(entity, q, row, opts, getMeta(entity).version));
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The write every update runs, matching the version `lockKey` names where one is being held. Only a
|
|
240
|
+
* restore passes none: it writes no content, so there is no update of anyone's to lose.
|
|
241
|
+
*/
|
|
242
|
+
async updateRows(entity, q, row, opts, lockKey) {
|
|
221
243
|
const meta = getMeta(entity);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
return changes;
|
|
246
|
-
});
|
|
244
|
+
fillOnFields(meta, [row], 'onUpdate');
|
|
245
|
+
const relKeys = filterPersistableRelationKeys(meta, row, 'persist');
|
|
246
|
+
const settles = !!relKeys.length || this.settlesWrite(entity, q);
|
|
247
|
+
if (lockKey) {
|
|
248
|
+
assertLockableUpdate(meta, q, settles);
|
|
249
|
+
const lock = lockVersion(meta, lockKey, q, row);
|
|
250
|
+
row[lockKey] = lock.next;
|
|
251
|
+
const changes = await this.updateColumns(entity, lock.q, row, opts, 0);
|
|
252
|
+
return changes || this.throwStaleVersion(entity, lockKey, q, lock.expected, opts);
|
|
253
|
+
}
|
|
254
|
+
if (!settles) {
|
|
255
|
+
return this.updateColumns(entity, q, row, opts, 0);
|
|
256
|
+
}
|
|
257
|
+
const ids = await this.settleIds(entity, q, opts);
|
|
258
|
+
if (!ids.length) {
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
const changes = await this.updateColumns(entity, { $where: whereIds(meta, ids) }, row, opts, ids.length);
|
|
262
|
+
for (const relKey of relKeys) {
|
|
263
|
+
await this.saveRelation(entity, relKey, ids.map((id) => ({ id, value: row[relKey] })), true);
|
|
264
|
+
}
|
|
265
|
+
return changes;
|
|
247
266
|
}
|
|
248
267
|
/**
|
|
249
|
-
* Why an update matched no row
|
|
250
|
-
*
|
|
268
|
+
* Why an update matched no row. The filter named the row by its id, so reading by that id alone
|
|
269
|
+
* separates the three: the row is gone, another writer moved the version on, or the rest of the
|
|
270
|
+
* filter excluded a row still at that version. One read, only on the failure, so the happy path
|
|
251
271
|
* still costs one statement. Best effort by nature - the row can change again while we ask.
|
|
252
272
|
*/
|
|
253
273
|
async throwStaleVersion(entity, key, q, expected, opts) {
|
|
254
274
|
const meta = getMeta(entity);
|
|
255
|
-
const
|
|
275
|
+
const where = q.$where;
|
|
276
|
+
const byId = Object.fromEntries(meta.ids.map((id) => [id, where[id]]));
|
|
277
|
+
const row = await this.findOne(entity, { $select: { [key]: true }, $where: byId }, opts);
|
|
256
278
|
const actual = row?.[key];
|
|
257
|
-
|
|
258
|
-
? `no row of '${entityName(meta)}'
|
|
259
|
-
:
|
|
279
|
+
const message = actual === undefined
|
|
280
|
+
? `no row of '${entityName(meta)}' has that id any more: it is gone`
|
|
281
|
+
: actual === expected
|
|
282
|
+
? `'${entityName(meta)}' is still at '${key}' ${String(actual)}: another condition of the update's '$where' excluded it`
|
|
283
|
+
: `'${entityName(meta)}' moved on: the payload carries '${key}' ${String(expected)}, the row is at ${String(actual)}`;
|
|
284
|
+
throw new UqlOptimisticLockError(message, expected, actual);
|
|
260
285
|
}
|
|
261
286
|
/** The UPDATE, skipped where the payload writes no column, reporting `unwritten` instead. */
|
|
262
287
|
async updateColumns(entity, q, row, opts, unwritten) {
|
|
@@ -286,16 +311,15 @@ export class AbstractQuerier {
|
|
|
286
311
|
if (!meta.softDelete) {
|
|
287
312
|
throw new TypeError(`'${entity.name}' has not enabled 'softDelete'`);
|
|
288
313
|
}
|
|
289
|
-
assertUnversioned(meta, 'restoreMany');
|
|
290
314
|
const $where = { ...q.$where, [meta.softDelete]: { $ne: null } };
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
});
|
|
315
|
+
// No version: a restore only undoes the stamp a delete left, which takes none either, and two of
|
|
316
|
+
// them racing agree on the result anyway. A lock is for content, and a restore writes none.
|
|
317
|
+
return this.hooked(entity, 'Update', [{ [meta.softDelete]: null }], ([row]) => this.updateRows(entity, { ...q, $where }, row, { filters: { softDelete: false } }, undefined));
|
|
294
318
|
}
|
|
295
319
|
/** Fires `beforeUpsert`/`afterUpsert`: which branch a row takes is the database's to decide, so neither the insert's nor the update's pair fits. */
|
|
296
320
|
async upsertOne(entity, conflictPaths, payload) {
|
|
297
321
|
const meta = getMeta(entity);
|
|
298
|
-
assertUnversioned(meta, 'upsertOne');
|
|
322
|
+
assertUnversioned(meta, "'upsertOne'");
|
|
299
323
|
return this.hooked(entity, 'Upsert', [payload], async (rows) => {
|
|
300
324
|
const { ids, changes, created } = await this.internalUpsertOne(entity, conflictPaths, rows[0]);
|
|
301
325
|
adoptReportedIds(meta, rows, ids);
|
|
@@ -305,7 +329,7 @@ export class AbstractQuerier {
|
|
|
305
329
|
}
|
|
306
330
|
async upsertMany(entity, conflictPaths, payload) {
|
|
307
331
|
const meta = getMeta(entity);
|
|
308
|
-
assertUnversioned(meta, 'upsertMany');
|
|
332
|
+
assertUnversioned(meta, "'upsertMany'");
|
|
309
333
|
return this.hooked(entity, 'Upsert', payload, async (rows) => {
|
|
310
334
|
const { ids, changes } = await this.internalUpsertMany(entity, conflictPaths, rows);
|
|
311
335
|
adoptReportedIds(meta, rows, ids);
|
|
@@ -343,8 +367,6 @@ export class AbstractQuerier {
|
|
|
343
367
|
return changes;
|
|
344
368
|
}
|
|
345
369
|
async saveOne(entity, payload) {
|
|
346
|
-
// Named here as well as in `saveMany`, so the refusal names the method the caller reached for.
|
|
347
|
-
assertUnversioned(getMeta(entity), 'saveOne');
|
|
348
370
|
const [id] = await this.saveMany(entity, [payload]);
|
|
349
371
|
return id;
|
|
350
372
|
}
|
|
@@ -355,7 +377,7 @@ export class AbstractQuerier {
|
|
|
355
377
|
*/
|
|
356
378
|
async saveMany(entity, payload) {
|
|
357
379
|
const meta = getMeta(entity);
|
|
358
|
-
assertUnversioned(meta, '
|
|
380
|
+
assertUnversioned(meta, "'save'");
|
|
359
381
|
// Indexes, not rows: the result is reported in payload order so it can be zipped with what was
|
|
360
382
|
// passed, which concatenating the branches did not do.
|
|
361
383
|
const toInsert = [];
|
|
@@ -24,6 +24,16 @@ export declare class UqlOptimisticLockError extends Error {
|
|
|
24
24
|
readonly status = 409;
|
|
25
25
|
constructor(message: string, expected: unknown, actual: unknown);
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Thrown where a write cannot carry the optimistic lock: an update payload without its version, or a
|
|
29
|
+
* method with no version to match. A `TypeError` still, since the caller used the API wrong, but one
|
|
30
|
+
* carrying the `status` an HTTP transport answers with - the request is malformed, not the server's
|
|
31
|
+
* failure, and an untyped client is exactly who reaches this.
|
|
32
|
+
*/
|
|
33
|
+
export declare class UqlLockUsageError extends TypeError {
|
|
34
|
+
name: string;
|
|
35
|
+
readonly status = 400;
|
|
36
|
+
}
|
|
27
37
|
/**
|
|
28
38
|
* Names what `err` ran into on any engine, or `undefined` for anything else. Pure: the error is only
|
|
29
39
|
* read, so it works on any driver error, whether or not a querier saw it first.
|
|
@@ -14,6 +14,16 @@ export class UqlOptimisticLockError extends Error {
|
|
|
14
14
|
this.actual = actual;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Thrown where a write cannot carry the optimistic lock: an update payload without its version, or a
|
|
19
|
+
* method with no version to match. A `TypeError` still, since the caller used the API wrong, but one
|
|
20
|
+
* carrying the `status` an HTTP transport answers with - the request is malformed, not the server's
|
|
21
|
+
* failure, and an untyped client is exactly who reaches this.
|
|
22
|
+
*/
|
|
23
|
+
export class UqlLockUsageError extends TypeError {
|
|
24
|
+
name = 'UqlLockUsageError';
|
|
25
|
+
status = 400;
|
|
26
|
+
}
|
|
17
27
|
/** Postgres, CockroachDB, PGlite and Neon in `code`; Bun SQL in `errno`. */
|
|
18
28
|
const SQLSTATE_KINDS = new Map([
|
|
19
29
|
['23505', 'uniqueViolation'],
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"homepage": "https://uql-orm.dev",
|
|
4
4
|
"description": "The JSON-native TypeScript ORM for Bun, Browsers, Edge, Deno, Node, Workers. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, SQL Server, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.77.0",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24"
|
package/skills/uql-orm/SKILL.md
CHANGED
|
@@ -82,9 +82,7 @@ export class Post {
|
|
|
82
82
|
- `@Field({ type: Number, version: true })`, with `[versionKey]?: 'version'` on the class, makes the column an
|
|
83
83
|
optimistic lock: every update payload must carry the version it read (a compile error otherwise), the update
|
|
84
84
|
matches on it and writes the next one, and a write against a row someone else moved on throws
|
|
85
|
-
`UqlOptimisticLockError` (`status` 409) instead of overwriting it. Save
|
|
86
|
-
an entity (restore with `updateOneById`, `{ filters: { softDelete: false } }`); `updateMany` writes only the rows
|
|
87
|
-
still at the version it carries, and delete needs none.
|
|
85
|
+
`UqlOptimisticLockError` (`status` 409) instead of overwriting it. Save and upsert are refused on such an entity; the update is named by its id, so `updateMany` over a many-row filter is refused too, and delete and restore carry no version.
|
|
88
86
|
- `defineEntity` defines the same entity without decorators: https://uql-orm.dev/entities/imperative.md
|
|
89
87
|
|
|
90
88
|
## Queries
|