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.
@@ -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
- return this.hooked(entity, 'Update', [payload], async ([row]) => {
200
- fillOnFields(meta, [row], 'onUpdate');
201
- const relKeys = filterPersistableRelationKeys(meta, row, 'persist');
202
- if (!relKeys.length && !this.settlesWrite(entity, q)) {
203
- return this.updateColumns(entity, q, row, opts, 0);
204
- }
205
- const ids = await this.settleIds(entity, q, opts);
206
- if (!ids.length) {
207
- return 0;
208
- }
209
- const changes = await this.updateColumns(entity, { $where: whereIds(meta, ids) }, row, opts, ids.length);
210
- for (const relKey of relKeys) {
211
- await this.saveRelation(entity, relKey, ids.map((id) => ({ id, value: row[relKey] })), true);
212
- }
213
- return changes;
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
- return this.updateMany(entity, { ...q, $where }, { [meta.softDelete]: null }, {
246
- filters: { softDelete: false },
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) ??
@@ -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
- /** A partial write as a caller supplies one: {@link UpdatePayload} without them. */
34
- export type UpdateWrite<E, Raw = QueryRaw> = UpdatePayload<E, Raw, WritableKey<E>>;
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: {
@@ -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
- const keys = getKeys(meta.fields).filter((key) => meta.fields[key][callbackKey]);
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
- * What a column the *database* writes cannot use. A stored computed column is a real column - it has
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
- * Whatever leaves `key` unread, named for the message, or `undefined` where the field reads it. Only
64
- * `nullable: true` contradicts a key: `nullable: false` says what the key already is, and rejecting
65
- * an accurate statement teaches an author to distrust the check.
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 && key === 'nullable' && opts.nullable === 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.75.0",
6
+ "version": "0.77.0",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"
@@ -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