uql-orm 0.75.0 → 0.76.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,12 @@ 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
+ * Why an update matched no row: another writer moved the version on, or the row is gone. One read
91
+ * without the version predicate answers it, and it runs only on the failure, so the happy path
92
+ * still costs one statement. Best effort by nature - the row can change again while we ask.
93
+ */
94
+ private throwStaleVersion;
89
95
  /** The UPDATE, skipped where the payload writes no column, reporting `unwritten` instead. */
90
96
  private updateColumns;
91
97
  /**
@@ -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, 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,29 @@ 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 value the payload carried, out of the payload and
43
+ * into the filter, and the next one back in its place. The bump is a plain value rather than SQL
44
+ * arithmetic, since the filter pins what the column holds, which spares every engine a read-back.
45
+ */
46
+ function lockVersion(meta, key, q, row) {
47
+ const expected = row[key];
48
+ if (expected === undefined || expected === null) {
49
+ throw new TypeError(`an update of '${entityName(meta)}' carries no '${key}': a versioned row is written against the version it was read at`);
50
+ }
51
+ row[key] = (typeof expected === 'bigint' ? expected + 1n : Number(expected) + 1);
52
+ return { expected, q: { ...q, $where: { $and: [q.$where ?? {}, { [key]: expected }] } } };
53
+ }
54
+ /**
55
+ * Refuses a write that cannot carry the lock, rather than writing over whatever the row holds now.
56
+ * An upsert has no portable way to match a version - MySQL's `ON DUPLICATE KEY UPDATE` takes no
57
+ * `WHERE` - and a write the library itself composes has no version to carry.
58
+ */
59
+ function assertUnversioned(meta, method) {
60
+ if (meta.version) {
61
+ throw new TypeError(`cannot '${method}' the versioned '${entityName(meta)}': it carries no '${meta.version}' to match, so update it by id`);
62
+ }
63
+ }
41
64
  /**
42
65
  * The id each written row is named by, in payload order. Read off the rows as written, so a key the
43
66
  * database generated or the ORM filled is there, and a composite is named by every column of it.
@@ -198,9 +221,18 @@ export class AbstractQuerier {
198
221
  const meta = getMeta(entity);
199
222
  return this.hooked(entity, 'Update', [payload], async ([row]) => {
200
223
  fillOnFields(meta, [row], 'onUpdate');
224
+ const { version } = meta;
225
+ const lock = version && lockVersion(meta, version, q, row);
201
226
  const relKeys = filterPersistableRelationKeys(meta, row, 'persist');
202
227
  if (!relKeys.length && !this.settlesWrite(entity, q)) {
203
- return this.updateColumns(entity, q, row, opts, 0);
228
+ const changes = await this.updateColumns(entity, lock ? lock.q : q, row, opts, 0);
229
+ return lock && !changes ? this.throwStaleVersion(entity, version, q, lock.expected, opts) : changes;
230
+ }
231
+ if (lock) {
232
+ // Everything below reads the ids first and writes them in a second statement, which puts the
233
+ // race back in the gap between the two - the very thing the version is here to close.
234
+ // `settlesWrite` covers the paged forms, so `$sort`, `$limit` and `$skip` land here as well.
235
+ throw new TypeError(`cannot update '${entityName(meta)}' this way: a versioned row is matched and written in one statement, so it takes no '$sort', '$limit' or '$skip', writes no relation, and filters by none`);
204
236
  }
205
237
  const ids = await this.settleIds(entity, q, opts);
206
238
  if (!ids.length) {
@@ -213,6 +245,19 @@ export class AbstractQuerier {
213
245
  return changes;
214
246
  });
215
247
  }
248
+ /**
249
+ * Why an update matched no row: another writer moved the version on, or the row is gone. One read
250
+ * without the version predicate answers it, and it runs only on the failure, so the happy path
251
+ * still costs one statement. Best effort by nature - the row can change again while we ask.
252
+ */
253
+ async throwStaleVersion(entity, key, q, expected, opts) {
254
+ const meta = getMeta(entity);
255
+ const row = await this.findOne(entity, { $select: { [key]: true }, $where: q.$where }, opts);
256
+ const actual = row?.[key];
257
+ throw new UqlOptimisticLockError(actual === undefined
258
+ ? `no row of '${entityName(meta)}' matched the update: it is gone, or the filter names none`
259
+ : `'${entityName(meta)}' moved on: the payload carries '${key}' ${String(expected)}, the row is at ${String(actual)}`, expected, actual);
260
+ }
216
261
  /** The UPDATE, skipped where the payload writes no column, reporting `unwritten` instead. */
217
262
  async updateColumns(entity, q, row, opts, unwritten) {
218
263
  const writes = filterFieldKeys(getMeta(entity), row, 'onUpdate').length > 0;
@@ -241,6 +286,7 @@ export class AbstractQuerier {
241
286
  if (!meta.softDelete) {
242
287
  throw new TypeError(`'${entity.name}' has not enabled 'softDelete'`);
243
288
  }
289
+ assertUnversioned(meta, 'restoreMany');
244
290
  const $where = { ...q.$where, [meta.softDelete]: { $ne: null } };
245
291
  return this.updateMany(entity, { ...q, $where }, { [meta.softDelete]: null }, {
246
292
  filters: { softDelete: false },
@@ -249,6 +295,7 @@ export class AbstractQuerier {
249
295
  /** 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
296
  async upsertOne(entity, conflictPaths, payload) {
251
297
  const meta = getMeta(entity);
298
+ assertUnversioned(meta, 'upsertOne');
252
299
  return this.hooked(entity, 'Upsert', [payload], async (rows) => {
253
300
  const { ids, changes, created } = await this.internalUpsertOne(entity, conflictPaths, rows[0]);
254
301
  adoptReportedIds(meta, rows, ids);
@@ -258,6 +305,7 @@ export class AbstractQuerier {
258
305
  }
259
306
  async upsertMany(entity, conflictPaths, payload) {
260
307
  const meta = getMeta(entity);
308
+ assertUnversioned(meta, 'upsertMany');
261
309
  return this.hooked(entity, 'Upsert', payload, async (rows) => {
262
310
  const { ids, changes } = await this.internalUpsertMany(entity, conflictPaths, rows);
263
311
  adoptReportedIds(meta, rows, ids);
@@ -295,6 +343,8 @@ export class AbstractQuerier {
295
343
  return changes;
296
344
  }
297
345
  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');
298
348
  const [id] = await this.saveMany(entity, [payload]);
299
349
  return id;
300
350
  }
@@ -305,6 +355,7 @@ export class AbstractQuerier {
305
355
  */
306
356
  async saveMany(entity, payload) {
307
357
  const meta = getMeta(entity);
358
+ assertUnversioned(meta, 'saveMany');
308
359
  // Indexes, not rows: the result is reported in payload order so it can be zipped with what was
309
360
  // passed, which concatenating the branches did not do.
310
361
  const toInsert = [];
@@ -409,6 +460,9 @@ export class AbstractQuerier {
409
460
  }
410
461
  /** Each parent gets its own referenced row, and its own column pointing at it. */
411
462
  async saveManyToOne(entity, relEntity, localColumn, writes) {
463
+ // Before anything is written: the follow-up that points each row at its new relation carries no
464
+ // version, and half an insert is worse than a refusal.
465
+ assertUnversioned(getMeta(entity), 'save a to-one relation of');
412
466
  const pointing = writes.filter(({ value }) => value);
413
467
  const referenceIds = await this.insertMany(relEntity, pointing.map(({ value }) => value));
414
468
  for (const [index, { id }] of pointing.entries()) {
@@ -11,7 +11,19 @@ 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
+ }
15
27
  /**
16
28
  * Names what `err` ran into on any engine, or `undefined` for anything else. Pure: the error is only
17
29
  * read, so it works on any driver error, whether or not a querier saw it first.
@@ -1,3 +1,19 @@
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
+ }
1
17
  /** Postgres, CockroachDB, PGlite and Neon in `code`; Bun SQL in `errno`. */
2
18
  const SQLSTATE_KINDS = new Map([
3
19
  ['23505', 'uniqueViolation'],
@@ -52,6 +68,9 @@ export function queryErrorKind(err) {
52
68
  if (typeof err !== 'object' || err === null) {
53
69
  return undefined;
54
70
  }
71
+ if (err instanceof UqlOptimisticLockError) {
72
+ return 'optimisticLock';
73
+ }
55
74
  const { code, errno, number, errorLabels, message } = err;
56
75
  const text = typeof message === 'string' ? message : '';
57
76
  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.76.0",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"
@@ -79,6 +79,12 @@ 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, upsert and `restoreMany` are refused on such
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.
82
88
  - `defineEntity` defines the same entity without decorators: https://uql-orm.dev/entities/imperative.md
83
89
 
84
90
  ## Queries