uql-orm 0.75.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.
- package/dist/entity/decorator/members.d.ts +15 -3
- package/dist/entity/metadata/definition.js +12 -0
- package/dist/querier/abstractQuerier.d.ts +12 -0
- package/dist/querier/abstractQuerier.js +96 -20
- package/dist/querier/queryError.d.ts +23 -1
- package/dist/querier/queryError.js +29 -0
- package/dist/type/entity.d.ts +28 -2
- package/dist/type/entity.js +6 -0
- package/dist/util/dialect.util.js +3 -1
- package/dist/util/fieldOption.util.d.ts +15 -2
- package/dist/util/fieldOption.util.js +25 -10
- package/package.json +1 -1
- package/skills/uql-orm/SKILL.md +4 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AggregateValue, ComputedRefs, EntityAggregate, EntityGetter, Except, FieldOptions, FieldType, HasCompositeKey, IdValue, NamedIdKey, RejectKeys, RelationManyToManyOptions, RelationManyToOneOptions, RelationOneToManyOptions, RelationOneToOneOptions, RelationAggregate, TsTypeOf, Writable } from '../../type/index.js';
|
|
1
|
+
import type { AggregateValue, ComputedRefs, EntityAggregate, EntityGetter, Except, FieldOptions, FieldType, HasCompositeKey, IdValue, NamedIdKey, VersionKey, RejectKeys, RelationManyToManyOptions, RelationManyToOneOptions, RelationOneToManyOptions, RelationOneToOneOptions, RelationAggregate, TsTypeOf, Writable } from '../../type/index.js';
|
|
2
2
|
import type { RejectIncompatible } from '../../util/index.js';
|
|
3
3
|
/** A member decorator that also constrains the property it may be applied to, on a class `O`. */
|
|
4
4
|
type MemberDecorator<V, O = unknown> = (value: undefined, context: ClassFieldDecoratorContext<O, V>) => void;
|
|
@@ -31,12 +31,14 @@ type DeclaredValue<O> = O extends {
|
|
|
31
31
|
/**
|
|
32
32
|
* The `null` a column reads back: every one holds it unless `nullable: false` says otherwise, so the
|
|
33
33
|
* property admits it too. A key holds none, whether `@Id` or `@Field({ isId: true })` declares it, since
|
|
34
|
-
* it is NOT NULL on every engine.
|
|
34
|
+
* it is NOT NULL on every engine, and neither does a version, which is NOT NULL DEFAULT 0.
|
|
35
35
|
*/
|
|
36
36
|
type NullOf<O> = O extends {
|
|
37
37
|
readonly nullable: false;
|
|
38
38
|
} | {
|
|
39
39
|
readonly isId: true;
|
|
40
|
+
} | {
|
|
41
|
+
readonly version: true;
|
|
40
42
|
} ? never : null;
|
|
41
43
|
/** The enum's members, or a named complaint where they widened for lack of `as const`, which would check nothing. */
|
|
42
44
|
type EnumValue<Members, Declared> = Declared extends Members ? {
|
|
@@ -51,7 +53,7 @@ export declare function Field<This, O extends FieldOptions<DeclaredValue<O>, Thi
|
|
|
51
53
|
type: FieldType;
|
|
52
54
|
} | {
|
|
53
55
|
references: EntityGetter;
|
|
54
|
-
}) & RejectKeys<Exclude<keyof O, keyof FieldOptions>> & RejectIncompatible<O>>(opts: O): AdmittingDecorator<DeclaredValue<O> | NullOf<O>, NullOf<O>, This>;
|
|
56
|
+
}) & RejectKeys<Exclude<keyof O, keyof FieldOptions>> & RejectIncompatible<O>>(opts: O & VersionIsBranded<O, This>): AdmittingDecorator<DeclaredValue<O> | NullOf<O>, NullOf<O>, This>;
|
|
55
57
|
/**
|
|
56
58
|
* Declares a field a relation aggregate computes, `@Field({ computed: (user) => user.resources.count() })`.
|
|
57
59
|
* The aggregate says what the field holds, so it takes no `type`, and only `count` and `sum` - the two a
|
|
@@ -80,6 +82,16 @@ type AggregateOptions<E> = (Except<FieldOptions<never, E>, 'computed' | 'stored'
|
|
|
80
82
|
type KeyIsNamed<This> = [NamedIdKey<This>] extends [never] ? {
|
|
81
83
|
readonly __keyNeedsIdKeyBrand: true;
|
|
82
84
|
} : unknown;
|
|
85
|
+
/**
|
|
86
|
+
* An optimistic lock the type level cannot see. `version: true` is what makes the column a lock at run
|
|
87
|
+
* time; the `versionKey` brand is what makes an update payload require it, and a lock only half
|
|
88
|
+
* declared would be a guarantee nothing enforces, so the decorator asks for both.
|
|
89
|
+
*/
|
|
90
|
+
type VersionIsBranded<O, This> = O extends {
|
|
91
|
+
readonly version: true;
|
|
92
|
+
} ? [VersionKey<This>] extends [never] ? {
|
|
93
|
+
readonly __versionNeedsVersionKeyBrand: true;
|
|
94
|
+
} : unknown : unknown;
|
|
83
95
|
/** {@link MemberDecorator} that also constrains the class, which is where a key is named. */
|
|
84
96
|
type IdDecorator<V> = <This>(value: undefined, context: ClassFieldDecoratorContext<This, V> & KeyIsNamed<This>) => void;
|
|
85
97
|
/**
|
|
@@ -213,6 +213,18 @@ export function defineEntity(entity, opts = {}) {
|
|
|
213
213
|
meta.softDelete = softDeleteKeys[0];
|
|
214
214
|
(meta.filters ??= {})[SOFT_DELETE_FILTER] = { where: { [meta.softDelete]: null }, default: true };
|
|
215
215
|
}
|
|
216
|
+
// The optimistic lock, derived the same way and just as singular: one row has one version.
|
|
217
|
+
const versionKeys = getKeys(meta.fields).filter((key) => meta.fields[key]?.version);
|
|
218
|
+
if (versionKeys.length > 1) {
|
|
219
|
+
throw TypeError(`'${entity.name}' must have at most one field with 'version'`);
|
|
220
|
+
}
|
|
221
|
+
if (versionKeys.length) {
|
|
222
|
+
meta.version = versionKeys[0];
|
|
223
|
+
// Implied rather than stated: the DDL default covers a row written around the ORM, and `onInsert`
|
|
224
|
+
// covers MongoDB, which has no DDL to default. Both, so every backend starts a row at the same 0.
|
|
225
|
+
const field = meta.fields[meta.version];
|
|
226
|
+
meta.fields[meta.version] = { ...field, nullable: false, defaultValue: 0, onInsert: 0 };
|
|
227
|
+
}
|
|
216
228
|
const ids = getIdKeys(meta);
|
|
217
229
|
if (!ids.length) {
|
|
218
230
|
throw TypeError(`'${entity.name}' must have at least one id field (use @Id, defineId, or defineEntity({ fields: { ..., isId: true } }))`);
|
|
@@ -86,6 +86,18 @@ export declare abstract class AbstractQuerier implements Querier {
|
|
|
86
86
|
updateOneById<E extends object>(entity: Type<E>, id: EntityId<E>, payload: UpdateWrite<E>, opts?: QueryOptions): Promise<number>;
|
|
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
|
+
/**
|
|
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
|
|
98
|
+
* still costs one statement. Best effort by nature - the row can change again while we ask.
|
|
99
|
+
*/
|
|
100
|
+
private throwStaleVersion;
|
|
89
101
|
/** The UPDATE, skipped where the payload writes no column, reporting `unwritten` instead. */
|
|
90
102
|
private updateColumns;
|
|
91
103
|
/**
|
|
@@ -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 } 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.
|
|
@@ -38,6 +38,44 @@ function assertNamesRows(entity, method, q, opts) {
|
|
|
38
38
|
}
|
|
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
|
+
/**
|
|
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
|
+
*/
|
|
46
|
+
function lockVersion(meta, key, q, row) {
|
|
47
|
+
const expected = row[key];
|
|
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
|
+
}
|
|
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 } } };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Refuses a write that cannot carry the lock, rather than writing over whatever the row holds now.
|
|
58
|
+
* An upsert has no portable way to match a version - MySQL's `ON DUPLICATE KEY UPDATE` takes no
|
|
59
|
+
* `WHERE` - and a write the library itself composes has no version to carry.
|
|
60
|
+
*/
|
|
61
|
+
function assertUnversioned(meta, what) {
|
|
62
|
+
if (meta.version) {
|
|
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`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
41
79
|
/**
|
|
42
80
|
* The id each written row is named by, in payload order. Read off the rows as written, so a key the
|
|
43
81
|
* database generated or the ORM filled is there, and a composite is named by every column of it.
|
|
@@ -195,23 +233,55 @@ export class AbstractQuerier {
|
|
|
195
233
|
/** Settles the rows first where the update cascades, so a payload changing what `$where` reads still names them. */
|
|
196
234
|
async updateMany(entity, q, payload, opts) {
|
|
197
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) {
|
|
198
243
|
const meta = getMeta(entity);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
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
|
|
271
|
+
* still costs one statement. Best effort by nature - the row can change again while we ask.
|
|
272
|
+
*/
|
|
273
|
+
async throwStaleVersion(entity, key, q, expected, opts) {
|
|
274
|
+
const meta = getMeta(entity);
|
|
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);
|
|
278
|
+
const actual = row?.[key];
|
|
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);
|
|
215
285
|
}
|
|
216
286
|
/** The UPDATE, skipped where the payload writes no column, reporting `unwritten` instead. */
|
|
217
287
|
async updateColumns(entity, q, row, opts, unwritten) {
|
|
@@ -242,13 +312,14 @@ export class AbstractQuerier {
|
|
|
242
312
|
throw new TypeError(`'${entity.name}' has not enabled 'softDelete'`);
|
|
243
313
|
}
|
|
244
314
|
const $where = { ...q.$where, [meta.softDelete]: { $ne: null } };
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
});
|
|
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));
|
|
248
318
|
}
|
|
249
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. */
|
|
250
320
|
async upsertOne(entity, conflictPaths, payload) {
|
|
251
321
|
const meta = getMeta(entity);
|
|
322
|
+
assertUnversioned(meta, "'upsertOne'");
|
|
252
323
|
return this.hooked(entity, 'Upsert', [payload], async (rows) => {
|
|
253
324
|
const { ids, changes, created } = await this.internalUpsertOne(entity, conflictPaths, rows[0]);
|
|
254
325
|
adoptReportedIds(meta, rows, ids);
|
|
@@ -258,6 +329,7 @@ export class AbstractQuerier {
|
|
|
258
329
|
}
|
|
259
330
|
async upsertMany(entity, conflictPaths, payload) {
|
|
260
331
|
const meta = getMeta(entity);
|
|
332
|
+
assertUnversioned(meta, "'upsertMany'");
|
|
261
333
|
return this.hooked(entity, 'Upsert', payload, async (rows) => {
|
|
262
334
|
const { ids, changes } = await this.internalUpsertMany(entity, conflictPaths, rows);
|
|
263
335
|
adoptReportedIds(meta, rows, ids);
|
|
@@ -305,6 +377,7 @@ export class AbstractQuerier {
|
|
|
305
377
|
*/
|
|
306
378
|
async saveMany(entity, payload) {
|
|
307
379
|
const meta = getMeta(entity);
|
|
380
|
+
assertUnversioned(meta, "'save'");
|
|
308
381
|
// Indexes, not rows: the result is reported in payload order so it can be zipped with what was
|
|
309
382
|
// passed, which concatenating the branches did not do.
|
|
310
383
|
const toInsert = [];
|
|
@@ -409,6 +482,9 @@ export class AbstractQuerier {
|
|
|
409
482
|
}
|
|
410
483
|
/** Each parent gets its own referenced row, and its own column pointing at it. */
|
|
411
484
|
async saveManyToOne(entity, relEntity, localColumn, writes) {
|
|
485
|
+
// Before anything is written: the follow-up that points each row at its new relation carries no
|
|
486
|
+
// version, and half an insert is worse than a refusal.
|
|
487
|
+
assertUnversioned(getMeta(entity), 'save a to-one relation of');
|
|
412
488
|
const pointing = writes.filter(({ value }) => value);
|
|
413
489
|
const referenceIds = await this.insertMany(relEntity, pointing.map(({ value }) => value));
|
|
414
490
|
for (const [index, { id }] of pointing.entries()) {
|
|
@@ -11,7 +11,29 @@ export interface QueryError extends Error {
|
|
|
11
11
|
* What a failed query ran into, named the same on every engine. `retryable` is a deadlock, a
|
|
12
12
|
* serialization failure, a lock timeout or a busy database: the transaction can simply run again.
|
|
13
13
|
*/
|
|
14
|
-
export type QueryErrorKind = 'uniqueViolation' | 'foreignKeyViolation' | 'notNullViolation' | 'checkViolation' | 'retryable';
|
|
14
|
+
export type QueryErrorKind = 'uniqueViolation' | 'foreignKeyViolation' | 'notNullViolation' | 'checkViolation' | 'optimisticLock' | 'retryable';
|
|
15
|
+
/**
|
|
16
|
+
* Thrown when an update's `@Field({ version })` no longer matches the row: another writer moved it on,
|
|
17
|
+
* or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
|
|
18
|
+
* where there is no row left. `status` is what an HTTP transport answers with.
|
|
19
|
+
*/
|
|
20
|
+
export declare class UqlOptimisticLockError extends Error {
|
|
21
|
+
readonly expected: unknown;
|
|
22
|
+
readonly actual: unknown;
|
|
23
|
+
name: string;
|
|
24
|
+
readonly status = 409;
|
|
25
|
+
constructor(message: string, expected: unknown, actual: unknown);
|
|
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
|
+
}
|
|
15
37
|
/**
|
|
16
38
|
* Names what `err` ran into on any engine, or `undefined` for anything else. Pure: the error is only
|
|
17
39
|
* read, so it works on any driver error, whether or not a querier saw it first.
|
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when an update's `@Field({ version })` no longer matches the row: another writer moved it on,
|
|
3
|
+
* or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
|
|
4
|
+
* where there is no row left. `status` is what an HTTP transport answers with.
|
|
5
|
+
*/
|
|
6
|
+
export class UqlOptimisticLockError extends Error {
|
|
7
|
+
expected;
|
|
8
|
+
actual;
|
|
9
|
+
name = 'UqlOptimisticLockError';
|
|
10
|
+
status = 409;
|
|
11
|
+
constructor(message, expected, actual) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.expected = expected;
|
|
14
|
+
this.actual = actual;
|
|
15
|
+
}
|
|
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
|
+
}
|
|
1
27
|
/** Postgres, CockroachDB, PGlite and Neon in `code`; Bun SQL in `errno`. */
|
|
2
28
|
const SQLSTATE_KINDS = new Map([
|
|
3
29
|
['23505', 'uniqueViolation'],
|
|
@@ -52,6 +78,9 @@ export function queryErrorKind(err) {
|
|
|
52
78
|
if (typeof err !== 'object' || err === null) {
|
|
53
79
|
return undefined;
|
|
54
80
|
}
|
|
81
|
+
if (err instanceof UqlOptimisticLockError) {
|
|
82
|
+
return 'optimisticLock';
|
|
83
|
+
}
|
|
55
84
|
const { code, errno, number, errorLabels, message } = err;
|
|
56
85
|
const text = typeof message === 'string' ? message : '';
|
|
57
86
|
return (SQLSTATE_KINDS.get(code) ??
|
package/dist/type/entity.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ import type { Except, ExactlyOne, IsEqual, IsMany, Json, Scalar, Type, Unpacked,
|
|
|
6
6
|
import type { VectorDistance, VectorIndexOptions, VectorIndexType } from './vector.js';
|
|
7
7
|
/** Brands the property an entity is identified by, where it is not `id`, `_id` or `uuid`. */
|
|
8
8
|
export declare const idKey: unique symbol;
|
|
9
|
+
/**
|
|
10
|
+
* Brands the property holding an entity's optimistic-lock version, which is what makes an update
|
|
11
|
+
* payload require it. No conventional name, unlike {@link idKey}: a field merely called `version`
|
|
12
|
+
* must not start demanding one.
|
|
13
|
+
*/
|
|
14
|
+
export declare const versionKey: unique symbol;
|
|
9
15
|
/** The filter `@Field({ softDelete })` registers, a name reserved against an entity's own filters. */
|
|
10
16
|
export declare const SOFT_DELETE_FILTER = "softDelete";
|
|
11
17
|
/** A filter name an entity may declare: any but {@link SOFT_DELETE_FILTER}, which a refusal names. */
|
|
@@ -30,8 +36,20 @@ export type WritableKey<E> = {
|
|
|
30
36
|
}[FieldKey<E>];
|
|
31
37
|
/** A whole-record write as a caller supplies one: {@link EntityData} without the fields it cannot write. */
|
|
32
38
|
export type EntityWrite<E> = EntityData<E, WritableKey<E>>;
|
|
33
|
-
/**
|
|
34
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The property an entity brands with {@link versionKey} as its optimistic lock, `never` where it
|
|
41
|
+
* brands none. The brand is what carries `@Field({ version: true })` to the type level, since a
|
|
42
|
+
* decorator's options never reach `E`.
|
|
43
|
+
*/
|
|
44
|
+
export type VersionKey<E> = E extends {
|
|
45
|
+
[versionKey]?: infer K;
|
|
46
|
+
} ? K & FieldKey<E> : never;
|
|
47
|
+
/**
|
|
48
|
+
* A partial write as a caller supplies one: {@link UpdatePayload} without the fields it cannot write,
|
|
49
|
+
* and with the version where the entity keeps one - a write that cannot say which row state it read
|
|
50
|
+
* is refused here rather than silently overwriting whatever is there now.
|
|
51
|
+
*/
|
|
52
|
+
export type UpdateWrite<E, Raw = QueryRaw> = UpdatePayload<E, Raw, WritableKey<E>> & Required<Pick<E, VersionKey<E>>>;
|
|
35
53
|
/** The relation names of an entity: every key but its fields and its methods, so the two sets cannot drift. */
|
|
36
54
|
export type RelationKey<E> = Exclude<Key<E>, FieldKey<E> | MethodKey<E>>;
|
|
37
55
|
/**
|
|
@@ -247,6 +265,12 @@ export type FieldOptions<V = TsTypeOf<FieldType>, E = unknown> = {
|
|
|
247
265
|
* stamps `new Date()`, anything else is the value or callback stamped, `softDelete: () => Date.now()`.
|
|
248
266
|
*/
|
|
249
267
|
readonly softDelete?: true | OnFieldCallback<V>;
|
|
268
|
+
/**
|
|
269
|
+
* Makes the column an optimistic lock: an update matches the version its payload carries and writes
|
|
270
|
+
* the next one, so a write against a row someone else moved on throws instead of overwriting it. The
|
|
271
|
+
* column is `NOT NULL DEFAULT 0`, and the entity brands the property with {@link versionKey}.
|
|
272
|
+
*/
|
|
273
|
+
readonly version?: true;
|
|
250
274
|
/** The SQL type, where it differs from the one `type` implies: `type: String, columnType: 'decimal'`. */
|
|
251
275
|
readonly columnType?: ColumnType;
|
|
252
276
|
/** A string column's length. */
|
|
@@ -643,6 +667,8 @@ export type EntityMeta<E> = {
|
|
|
643
667
|
/** Every column of the primary key, in declaration order. */
|
|
644
668
|
ids: readonly IdKey<E>[];
|
|
645
669
|
softDelete?: FieldKey<E>;
|
|
670
|
+
/** The optimistic lock's column, from `@Field({ version: true })`. */
|
|
671
|
+
version?: FieldKey<E>;
|
|
646
672
|
/** Named, default-on `$where` filters applied to every query unless bypassed. */
|
|
647
673
|
filters?: Record<string, FilterOptions<E>>;
|
|
648
674
|
fields: {
|
package/dist/type/entity.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** Brands the property an entity is identified by, where it is not `id`, `_id` or `uuid`. */
|
|
2
2
|
export const idKey = Symbol('idKey');
|
|
3
|
+
/**
|
|
4
|
+
* Brands the property holding an entity's optimistic-lock version, which is what makes an update
|
|
5
|
+
* payload require it. No conventional name, unlike {@link idKey}: a field merely called `version`
|
|
6
|
+
* must not start demanding one.
|
|
7
|
+
*/
|
|
8
|
+
export const versionKey = Symbol('versionKey');
|
|
3
9
|
/** The filter `@Field({ softDelete })` registers, a name reserved against an entity's own filters. */
|
|
4
10
|
export const SOFT_DELETE_FILTER = 'softDelete';
|
|
5
11
|
/** Every SQL column type a field may declare, by family: the unions below and `columnFamily` both read it. */
|
|
@@ -69,7 +69,9 @@ export function getSoftDeleteValue(field) {
|
|
|
69
69
|
/** Fills each field `callbackKey` generates on `payload` in place, where the caller left it unset. */
|
|
70
70
|
export function fillOnFields(meta, payload, callbackKey) {
|
|
71
71
|
const payloads = Array.isArray(payload) ? payload : [payload];
|
|
72
|
-
|
|
72
|
+
// By presence, not truthiness, as `addInsertFieldKeys` above reads it: `onInsert: 0` and `onInsert: ''`
|
|
73
|
+
// are values a caller meant, and a falsy one was silently never filled.
|
|
74
|
+
const keys = getKeys(meta.fields).filter((key) => meta.fields[key][callbackKey] !== undefined);
|
|
73
75
|
if (keys.length === 0) {
|
|
74
76
|
return payloads;
|
|
75
77
|
}
|
|
@@ -20,6 +20,7 @@ declare const FIELD_OPTION_FAMILY: {
|
|
|
20
20
|
readonly onInsert: '*';
|
|
21
21
|
readonly onUpdate: '*';
|
|
22
22
|
readonly softDelete: '*';
|
|
23
|
+
readonly version: 'numeric';
|
|
23
24
|
readonly columnType: '*';
|
|
24
25
|
readonly length: 'string';
|
|
25
26
|
readonly precision: 'numeric';
|
|
@@ -44,8 +45,15 @@ type InlineRead = (typeof INLINE_READS)[number];
|
|
|
44
45
|
* DDL, an index, a comment, a name - so only the write half is dead on one: the engine fills it, and
|
|
45
46
|
* `GENERATED ALWAYS AS` and `DEFAULT` are mutually exclusive on every engine that has both.
|
|
46
47
|
*/
|
|
47
|
-
declare const GENERATED_WRITES: readonly ["updatable", "onInsert", "onUpdate", "softDelete", "defaultValue", "autoIncrement"];
|
|
48
|
+
declare const GENERATED_WRITES: readonly ["updatable", "onInsert", "onUpdate", "softDelete", "defaultValue", "autoIncrement", "version"];
|
|
48
49
|
type GeneratedWrite = (typeof GENERATED_WRITES)[number];
|
|
50
|
+
/**
|
|
51
|
+
* What an optimistic lock cannot use: the querier writes the column on every update and matches the
|
|
52
|
+
* value the payload carried, so anything else deciding it would be fighting that, and the three that
|
|
53
|
+
* would make it another kind of column entirely. Its `nullable: false` and `DEFAULT 0` are implied.
|
|
54
|
+
*/
|
|
55
|
+
declare const VERSION_WRITES: readonly ["updatable", "onInsert", "onUpdate", "softDelete", "defaultValue", "autoIncrement", "computed", "stored", "isId"];
|
|
56
|
+
type VersionWrite = (typeof VERSION_WRITES)[number];
|
|
49
57
|
/**
|
|
50
58
|
* The first option `opts` cannot use, phrased as the tail of `'Entity.field' ...`, or `undefined`
|
|
51
59
|
* where every option applies. The runtime half of the decorators' check, so the imperative API and
|
|
@@ -68,7 +76,12 @@ type DeadOptions<O> = (O extends {
|
|
|
68
76
|
readonly nullable: true;
|
|
69
77
|
} ? 'nullable' : never) | (O extends {
|
|
70
78
|
readonly updatable: false;
|
|
71
|
-
} ? 'onUpdate' : never)
|
|
79
|
+
} ? 'onUpdate' : never) | (O extends {
|
|
80
|
+
readonly version: true;
|
|
81
|
+
} ? VersionWrite : never) | (O extends {
|
|
82
|
+
readonly version: true;
|
|
83
|
+
readonly nullable: true;
|
|
84
|
+
} ? 'nullable' : never);
|
|
72
85
|
type Given<O> = Extract<keyof O, keyof FieldOptions>;
|
|
73
86
|
type Offending<O> = {
|
|
74
87
|
[K in Given<O>]: (typeof FIELD_OPTION_FAMILY)[K] extends OptionsFamily<O> | '*' ? K extends DeadOptions<O> ? K : never : K;
|
|
@@ -21,6 +21,7 @@ const FIELD_OPTION_FAMILY = {
|
|
|
21
21
|
onInsert: '*',
|
|
22
22
|
onUpdate: '*',
|
|
23
23
|
softDelete: '*',
|
|
24
|
+
version: 'numeric',
|
|
24
25
|
columnType: '*',
|
|
25
26
|
length: 'string',
|
|
26
27
|
precision: 'numeric',
|
|
@@ -46,12 +47,8 @@ const INLINE_READS = [
|
|
|
46
47
|
'eager',
|
|
47
48
|
'distance',
|
|
48
49
|
];
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
* DDL, an index, a comment, a name - so only the write half is dead on one: the engine fills it, and
|
|
52
|
-
* `GENERATED ALWAYS AS` and `DEFAULT` are mutually exclusive on every engine that has both.
|
|
53
|
-
*/
|
|
54
|
-
const GENERATED_WRITES = [
|
|
50
|
+
/** Every option that decides what a column holds, or whether it is written at all. */
|
|
51
|
+
const VALUE_DECIDERS = [
|
|
55
52
|
'updatable',
|
|
56
53
|
'onInsert',
|
|
57
54
|
'onUpdate',
|
|
@@ -60,19 +57,37 @@ const GENERATED_WRITES = [
|
|
|
60
57
|
'autoIncrement',
|
|
61
58
|
];
|
|
62
59
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
60
|
+
* What a column the *database* writes cannot use. A stored computed column is a real column - it has
|
|
61
|
+
* DDL, an index, a comment, a name - so only the write half is dead on one: the engine fills it, and
|
|
62
|
+
* `GENERATED ALWAYS AS` and `DEFAULT` are mutually exclusive on every engine that has both.
|
|
63
|
+
*/
|
|
64
|
+
const GENERATED_WRITES = [...VALUE_DECIDERS, 'version'];
|
|
65
|
+
/**
|
|
66
|
+
* What an optimistic lock cannot use: the querier writes the column on every update and matches the
|
|
67
|
+
* value the payload carried, so anything else deciding it would be fighting that, and the three that
|
|
68
|
+
* would make it another kind of column entirely. Its `nullable: false` and `DEFAULT 0` are implied.
|
|
66
69
|
*/
|
|
70
|
+
const VERSION_WRITES = [...VALUE_DECIDERS, 'computed', 'stored', 'isId'];
|
|
71
|
+
/**
|
|
72
|
+
* Whether `key` is the `nullable: true` a NOT NULL column contradicts. `nullable: false` says what
|
|
73
|
+
* such a column already is, and rejecting an accurate statement teaches an author to distrust the check.
|
|
74
|
+
*/
|
|
75
|
+
function contradictsNotNull(opts, key) {
|
|
76
|
+
return key === 'nullable' && opts.nullable === true;
|
|
77
|
+
}
|
|
78
|
+
/** Whatever leaves `key` unread, named for the message, or `undefined` where the field reads it. */
|
|
67
79
|
function deadOn(opts, key) {
|
|
68
80
|
if (isInlinedExpression(opts) && !INLINE_READS.some((read) => read === key))
|
|
69
81
|
return 'an inlined computed field';
|
|
70
82
|
if (opts.stored === true && GENERATED_WRITES.some((write) => write === key))
|
|
71
83
|
return 'a stored computed column';
|
|
72
|
-
if (opts.isId === true &&
|
|
84
|
+
if (opts.isId === true && contradictsNotNull(opts, key))
|
|
73
85
|
return 'a primary key';
|
|
74
86
|
if (opts.updatable === false && key === 'onUpdate')
|
|
75
87
|
return "a field declared 'updatable: false'";
|
|
88
|
+
if (opts.version === true && (VERSION_WRITES.some((write) => write === key) || contradictsNotNull(opts, key))) {
|
|
89
|
+
return 'a version field';
|
|
90
|
+
}
|
|
76
91
|
return undefined;
|
|
77
92
|
}
|
|
78
93
|
/**
|
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
|
@@ -79,6 +79,10 @@ export class Post {
|
|
|
79
79
|
- Members are named by callbacks, never by strings: `mappedBy: (post) => post.author`, `references: (post) => post.authorId`.
|
|
80
80
|
- `@ManyToMany({ entity: () => Tag, through: () => PostTag })` names its junction entity.
|
|
81
81
|
- `@Index((post) => [post.authorId], { where: { archived: { $ne: true } } })` states a partial index's filter as the predicate the query passes, never as `raw`: a planner matches the two by shape, so `raw` that means the same thing leaves the index unused.
|
|
82
|
+
- `@Field({ type: Number, version: true })`, with `[versionKey]?: 'version'` on the class, makes the column an
|
|
83
|
+
optimistic lock: every update payload must carry the version it read (a compile error otherwise), the update
|
|
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 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.
|
|
82
86
|
- `defineEntity` defines the same entity without decorators: https://uql-orm.dev/entities/imperative.md
|
|
83
87
|
|
|
84
88
|
## Queries
|