@simtlix/simfinity-core 3.12.0 → 3.13.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/README.md CHANGED
@@ -45,3 +45,11 @@ Importing the runtime installs Simfinity's `__Field.extensions` introspection fi
45
45
  The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `UnauthenticatedError` and `ForbiddenError` (the same classes as `auth.UnauthenticatedError` and `auth.ForbiddenError`), `buildErrorFormatter`, `configureQueryLimits`, `configureMutationLimits`, `QLOperator`, `QLSort`, and `QLValue`. `buildErrorFormatter` returns a `GraphQLError` whose `originalError` is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
46
46
 
47
47
  The same helpers are available from the `@simtlix/simfinity-core/auth`, `/auth/errors`, `/auth/expressions`, `/auth/rules`, `/plugins`, `/scalars` and `/validators` subpaths, each with TypeScript declarations whose types match the root namespaces. `moduleResolution` `node16`, `nodenext` and `bundler` read them through the export map, and `node10` through `typesVersions`. The `internal/*` subpaths are not public API and have no declarations.
48
+
49
+ ## Optimistic concurrency
50
+
51
+ `extensions: { concurrency: { mode: 'optimistic' } }` opts an entity into the managed `version: RecordVersion!` field. Core exports `RecordVersion` and `MAX_RECORD_VERSION` (`9007199254740991`); import from the application's facade to share their identity. See the [concurrency guide](https://simtlix.github.io/simfinity.js/guide/concurrency.html).
52
+
53
+ An adapter advertises `writeFeatures: { optimisticConcurrency: true }`; `createRuntime()` snapshots/freezes this declaration and rejects invalid shapes with `INVALID_WRITE_FEATURES` (500). A protected schema rejects an adapter without support. `update(model, id, changes, session, { expectedVersion })` must atomically match ID/version, apply changes and set `expectedVersion + 1`, returning the updated record; `delete(model, id, session, { expectedVersion })` must atomically match both and return the deleted record. An update at the maximum skips the write. After no match, read the same cast key in the same session: return `null` if absent, report `VERSION_CONFLICT` if stale or `VERSION_EXHAUSTED` for an equal maximum on update, otherwise return `null`. MongoDB missing/null versions count as 1. Without `expectedVersion`, existing writes and call paths remain unchanged.
54
+
55
+ Support also requires `find(model, type, args, session, { requiredId, context })` to honor the session, required key and context for scoped visibility checks. Wrappers must forward every argument, including options. Core's update result diagnostic requires `expected + 1` and advancement beyond its immutable pre-read version; delete results must carry the expected version. These diagnostics raise `DATABASE_ERROR` for inconsistent results but cannot prove arbitrary wrappers kept the atomic predicate. They retain supplied-session ownership; adapters must preserve their own guarded-write abort semantics. The `get_by_id` scope defers stale/exhausted errors for hidden callers until write hooks authorize the operation, but malformed stored versions can fail before hooks. Protected writes always perform their required pre-read, regardless of `readByIdIsInert`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.12.0",
3
+ "version": "3.13.0",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "./internal/object-id": "./src/auth/object-id.js",
55
55
  "./internal/query-operators": "./src/query-operators.js",
56
+ "./internal/record-version": "./src/record-version.js",
56
57
  "./internal/relation-storage": "./src/relation-storage.js",
57
58
  "./plugins": {
58
59
  "types": "./types/plugins.d.ts",
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { describeModels } from './metadata.js';
2
2
  export { isVirtualField, virtualFieldOptions } from './relation-storage.js';
3
+ export { MAX_RECORD_VERSION, RecordVersion } from './record-version.js';
3
4
  export { default as SimfinityError } from './errors/simfinity.error.js';
4
5
  export { createValidatedScalar } from './scalars/factory.js';
5
6
  export {
package/src/metadata.js CHANGED
@@ -6,6 +6,10 @@ import {
6
6
  assertVirtualConnection, assertVirtualDeclaration, assertVirtualIndexes, assertVirtualStateField,
7
7
  } from './virtual-fields.js';
8
8
  import { assertPlainConnectionField } from './relation-storage.js';
9
+ import {
10
+ isProtectedType, isVersionField, recordVersionFieldMisuse, versionConnectionCollision, versionEmbeddedMisuse,
11
+ versionFieldCollision,
12
+ } from './record-version.js';
9
13
 
10
14
  const invalid = (message) => {
11
15
  throw new SimfinityError(message, 'INVALID_MODEL', 400);
@@ -62,10 +66,20 @@ const scalarDescription = (type) => {
62
66
  return { scalar };
63
67
  };
64
68
 
69
+ // The version field of a protected entity, described the same way whether the type carries Simfinity's
70
+ // injected field or only declares the extension and createSchema() has not injected it yet.
71
+ const versionDescription = () => ({
72
+ name: 'version', storageName: 'version', kind: 'scalar', scalar: 'Version', required: true,
73
+ list: false, itemRequired: false, unique: false, readOnly: true, version: true,
74
+ });
75
+
65
76
  /**
66
77
  * Describe persistent entities without importing a database driver or mutating GraphQL types.
67
78
  * Virtual fields (`extensions.virtual`) store nothing, so they are left out of every entity and
68
79
  * embedded type, and their result types are neither described nor required to declare a relation.
80
+ * An entity that declares optimistic concurrency (`extensions.concurrency`), or already carries its
81
+ * injected field, ends with the version field. A field that would collide with it, any other field
82
+ * typed RecordVersion, and a protected type used as an embedded value throw INVALID_MODEL.
69
83
  */
70
84
  export const describeModels = (registrations) => {
71
85
  if (!Array.isArray(registrations)) invalid('registrations must be an array');
@@ -91,10 +105,17 @@ export const describeModels = (registrations) => {
91
105
  const fieldsOf = (type, ancestors, path, embedded = false) => {
92
106
  if (ancestors.includes(type)) invalid(`Embedded cycle at ${path}`);
93
107
  registerType(type);
108
+ const isProtected = isProtectedType(type);
109
+ if (isProtected && embedded) invalid(versionEmbeddedMisuse(type.name, path));
110
+ const collision = isProtected ? versionFieldCollision(type) : null;
111
+ if (collision) invalid(collision);
112
+ const misuse = recordVersionFieldMisuse(type);
113
+ if (misuse) invalid(misuse);
94
114
  const stored = Object.values(type.getFields()).filter((field) => !assertVirtualDeclaration(
95
115
  type.name, field.name, field, () => typeof field.resolve === 'function',
96
116
  ));
97
117
  const fields = stored.map((field) => {
118
+ if (isVersionField(field)) return versionDescription();
98
119
  const { named, ...wrappers } = unwrap(field.type);
99
120
  const extensions = field.extensions || {};
100
121
  const descriptor = {
@@ -117,12 +138,15 @@ export const describeModels = (registrations) => {
117
138
  assertPlainConnectionField(type.name, field.name, field);
118
139
  addEntity(named);
119
140
  if (wrappers.list) {
141
+ const connectionCollision = versionConnectionCollision(type, field.name, field);
142
+ if (connectionCollision) invalid(connectionCollision);
120
143
  if (!relation.connectionField) invalid(`${path}.${field.name} requires a child connectionField`);
121
144
  if (embedded) invalid(`Non-embedded collections inside embedded objects are not supported: ${path}.${field.name}`);
122
145
  return { ...descriptor, kind: 'collection', target: named.name, connectionField: relation.connectionField };
123
146
  }
124
147
  return { ...descriptor, kind: 'reference', target: named.name, storageName: relation.connectionField || field.name };
125
148
  });
149
+ if (isProtected && !fields.some((item) => item.version)) fields.push(versionDescription());
126
150
  const storage = new Set();
127
151
  for (const field of fields.filter((item) => item.kind !== 'collection')) {
128
152
  if (storage.has(field.storageName)) invalid(`Storage collision at ${path}.${field.storageName}`);
@@ -0,0 +1,235 @@
1
+ import {
2
+ GraphQLError, GraphQLID, GraphQLInputObjectType, GraphQLNonNull, GraphQLScalarType, Kind, getNamedType, isObjectType,
3
+ } from 'graphql';
4
+ import SimfinityError from './errors/simfinity.error.js';
5
+ import { getFieldStorageName, getListShape } from './relation-storage.js';
6
+
7
+ // Everything about declarative optimistic concurrency (`extensions.concurrency`) that does not depend
8
+ // on a runtime: the RecordVersion scalar, the version field Simfinity injects into a protected type,
9
+ // how version values are read and checked, the errors that report them and the collision rules that
10
+ // createSchema() and describeModels() share. The scalar and its maximum are public root exports of the
11
+ // core package; the rest reaches the adapters through the `./internal/record-version` subpath, which
12
+ // is not public API.
13
+
14
+ /** The highest version. A record at it cannot be updated again, because its version cannot advance. */
15
+ export const MAX_RECORD_VERSION = Number.MAX_SAFE_INTEGER;
16
+
17
+ const RANGE = `an integer from 1 to ${MAX_RECORD_VERSION}`;
18
+
19
+ const isVersionNumber = (value) => Number.isSafeInteger(value) && value >= 1;
20
+
21
+ // A stored or serialized version as a number: a safe integer from 1 to MAX_RECORD_VERSION, given as a
22
+ // number or as a bigint (a PostgreSQL driver may return one for a bigint column). Null for anything else.
23
+ const asVersion = (value) => {
24
+ if (typeof value === 'bigint') return value >= 1n && value <= BigInt(MAX_RECORD_VERSION) ? Number(value) : null;
25
+ return isVersionNumber(value) ? value : null;
26
+ };
27
+
28
+ // The value as an error message shows it. A string is quoted; a value that cannot be converted to
29
+ // text, such as an object without a prototype, shows its type.
30
+ const show = (value) => {
31
+ try {
32
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
33
+ } catch {
34
+ return typeof value;
35
+ }
36
+ };
37
+
38
+ export const RecordVersion = new GraphQLScalarType({
39
+ name: 'RecordVersion',
40
+ description: 'Revision of a record with optimistic concurrency. Send it back unchanged in updates and deletes; Simfinity advances it.',
41
+ serialize(value) {
42
+ const version = asVersion(value);
43
+ if (version === null) throw new GraphQLError(`RecordVersion cannot represent ${show(value)}`);
44
+ return version;
45
+ },
46
+ // Input is a JSON number only: a string, a float or a bigint is never a version a client read.
47
+ parseValue(value) {
48
+ if (!isVersionNumber(value)) throw new TypeError(`RecordVersion must be ${RANGE}`);
49
+ return value;
50
+ },
51
+ parseLiteral(ast) {
52
+ // An IntValue beyond the safe integers rounds to an unsafe number, so the same check rejects it.
53
+ const version = ast.kind === Kind.INT ? Number(ast.value) : Number.NaN;
54
+ if (!isVersionNumber(version)) throw new GraphQLError(`RecordVersion must be ${RANGE}`, { nodes: ast });
55
+ return version;
56
+ },
57
+ });
58
+
59
+ /** A record of a protected collection, named by its ID and the version the client read. */
60
+ export const RecordVersionRef = new GraphQLInputObjectType({
61
+ name: 'RecordVersionRef',
62
+ fields: {
63
+ id: { type: new GraphQLNonNull(GraphQLID) },
64
+ version: { type: new GraphQLNonNull(RecordVersion) },
65
+ },
66
+ });
67
+
68
+ // The marker of the injected field. Symbol.for gives every copy of the core code the same key, and a
69
+ // null-prototype extensions object keeps it through toConfig() copies and extensions spreads.
70
+ export const RECORD_VERSION_FIELD = Symbol.for('simfinity.recordVersionField');
71
+ // Invalid raw storage values survive native hydration without mutating returned records.
72
+ // Weak keys also support frozen documents and do not keep query results alive.
73
+ const retainedVersions = new WeakMap();
74
+ // Undefined clears metadata when a later query reuses the same result object.
75
+ export const retainStoredVersion = (record, value) => {
76
+ if (value === undefined) retainedVersions.delete(record);
77
+ else retainedVersions.set(record, value);
78
+ };
79
+ export const retainedStoredVersion = (record) => retainedVersions.get(record);
80
+
81
+ /** Whether a field is the version field that Simfinity injected into a protected type. */
82
+ export const isVersionField = (field) => field?.extensions?.[RECORD_VERSION_FIELD] === true;
83
+
84
+ const VERSION_FIELD_TYPE = new GraphQLNonNull(RecordVersion);
85
+
86
+ /**
87
+ * The graphql-js field object that createSchema() puts into a protected type's `getFields()` map, with
88
+ * `resolve` as its resolver. `readOnly: true` keeps it out of the create input and `queryable: false`
89
+ * out of filters, sorts and aggregations; the marker tells it from an application field.
90
+ */
91
+ export const createVersionFieldConfig = (resolve) => ({
92
+ name: 'version',
93
+ description: 'Revision of this record. Send it back unchanged in updates and deletes; Simfinity advances it.',
94
+ type: VERSION_FIELD_TYPE,
95
+ args: [],
96
+ resolve,
97
+ deprecationReason: undefined,
98
+ extensions: Object.assign(Object.create(null), { readOnly: true, queryable: false, [RECORD_VERSION_FIELD]: true }),
99
+ astNode: undefined,
100
+ });
101
+
102
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
103
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
104
+
105
+ /**
106
+ * Whether the type declares optimistic concurrency. `extensions.concurrency` is either absent or a plain
107
+ * object whose only key is `mode`, with the value 'optimistic'; any other value throws INVALID_MODEL
108
+ * (400), whatever the key (own, hidden or a symbol) that makes it so.
109
+ */
110
+ export const concurrencyDeclared = (gqltype) => {
111
+ const declaration = gqltype.extensions?.concurrency;
112
+ if (declaration === undefined) return false;
113
+ const keys = isPlainObject(declaration) ? Reflect.ownKeys(declaration) : [];
114
+ if (keys.length !== 1 || keys[0] !== 'mode' || declaration.mode !== 'optimistic') {
115
+ throw new SimfinityError(`${gqltype.name} extensions.concurrency must be { mode: 'optimistic' }`, 'INVALID_MODEL', 400);
116
+ }
117
+ return true;
118
+ };
119
+
120
+ /**
121
+ * A stored version as a number. A missing or null one is 1 (a MongoDB document written before the type
122
+ * was protected); anything but a safe integer from 1 to MAX_RECORD_VERSION, given as a number or a
123
+ * bigint, throws DATABASE_ERROR (500): the stored data is wrong, not the request.
124
+ */
125
+ export const readStoredVersion = (value, { typeName, id } = {}) => {
126
+ if (value === undefined || value === null) return 1;
127
+ const version = asVersion(value);
128
+ if (version === null) {
129
+ const record = [typeName ?? 'Record', id].filter((part) => part !== undefined && part !== null).join(' ');
130
+ throw new SimfinityError(`${record} stores an invalid version`, 'DATABASE_ERROR', 500);
131
+ }
132
+ return version;
133
+ };
134
+
135
+ /**
136
+ * Checks a version that did not come through GraphQL coercion (middleware, saveObject(), a programmatic
137
+ * caller): a missing or null one throws REQUIRED_VALUE and one that is not a number from 1 to
138
+ * MAX_RECORD_VERSION throws INVALID_VERSION, both 400. Returns the version.
139
+ */
140
+ export const assertVersionInput = (value) => {
141
+ if (value === undefined || value === null) throw new SimfinityError('Required value version is missing', 'REQUIRED_VALUE', 400);
142
+ if (!isVersionNumber(value)) throw new SimfinityError(`Invalid value for version: expected ${RANGE}`, 'INVALID_VERSION', 400);
143
+ return value;
144
+ };
145
+
146
+ // None of these messages carries the stored version or other data of the record.
147
+ export const versionConflictError = (typeName, id) => new SimfinityError(
148
+ `${typeName} ${id} was modified by another operation`, 'VERSION_CONFLICT', 409,
149
+ );
150
+
151
+ export const versionExhaustedError = (typeName, id) => new SimfinityError(
152
+ `${typeName} ${id} has reached the maximum version`, 'VERSION_EXHAUSTED', 409,
153
+ );
154
+
155
+ /** `hook` is the name of the hook that set the managed field, such as 'onUpdating'. */
156
+ export const versionManagedError = (typeName, hook) => new SimfinityError(
157
+ `${typeName}.version is managed by Simfinity; ${hook} cannot set it`, 'VERSION_MANAGED', 500,
158
+ );
159
+
160
+ export const unadvancedVersionError = (typeName, id) => new SimfinityError(
161
+ `${typeName} ${id} was written without advancing its version`, 'DATABASE_ERROR', 500,
162
+ );
163
+
164
+ export const unexpectedDeleteError = (typeName, id) => new SimfinityError(
165
+ `${typeName} ${id} was deleted at another version`, 'DATABASE_ERROR', 500,
166
+ );
167
+
168
+ /**
169
+ * Whether the type is protected: it declares the extension, whose shape this checks (INVALID_MODEL), or
170
+ * already carries the injected field. describeModels() and the checks of createSchema() ask this, since
171
+ * they run before the field is injected.
172
+ */
173
+ export const isProtectedType = (gqltype) => concurrencyDeclared(gqltype) || Object.values(gqltype.getFields()).some(isVersionField);
174
+
175
+ /**
176
+ * The INVALID_MODEL message for a protected type used as an embedded value at `path`, such as
177
+ * `Pedido.items`: an embedded value's changes belong to the revision of the record that embeds it.
178
+ */
179
+ export const versionEmbeddedMisuse = (typeName, path) => `${typeName} declares optimistic concurrency, so it cannot be embedded (${path})`;
180
+
181
+ const collisionPrefix = (typeName) => `${typeName} declares optimistic concurrency, so Simfinity adds its version field, but`;
182
+
183
+ /**
184
+ * For a type that declares optimistic concurrency: the INVALID_MODEL message for the first field that
185
+ * would collide with the version field, or null. That is a field named `version` that Simfinity did not
186
+ * add, or a reference stored under `version` (`connectionField: 'version'`). The injected field itself
187
+ * is never a collision. A field named `version` typed RecordVersion is almost always the injected field
188
+ * copied without its marker, as a spread of its extensions passed to a graphql-js constructor does, so
189
+ * the message says how to keep it.
190
+ */
191
+ export const versionFieldCollision = (gqltype) => {
192
+ for (const [name, field] of Object.entries(gqltype.getFields())) {
193
+ if (isVersionField(field)) continue;
194
+ if (name === 'version') {
195
+ const copied = getNamedType(field.type) === RecordVersion
196
+ ? '. It is typed RecordVersion, so it was probably copied from a protected type without its marker: keep '
197
+ + 'the field\'s extensions object as it is, for example with toConfig()'
198
+ : '';
199
+ return `${collisionPrefix(gqltype.name)} ${gqltype.name} already has a field named version; rename that field${copied}`;
200
+ }
201
+ if (getFieldStorageName(name, field) === 'version') {
202
+ return `${collisionPrefix(gqltype.name)} ${gqltype.name}.${name} uses version as its connectionField; choose another one`;
203
+ }
204
+ }
205
+ return null;
206
+ };
207
+
208
+ /**
209
+ * For a collection (a list relation that is not embedded) of `ownerType.fieldName` whose item type
210
+ * declares optimistic concurrency: the INVALID_MODEL message when the collection links its items through
211
+ * `version`, by its connectionField or, without one, by its own field name; otherwise null. The link
212
+ * would resolve to the injected version field instead of a field of the application.
213
+ */
214
+ export const versionConnectionCollision = (ownerType, fieldName, field) => {
215
+ const relation = field.extensions?.relation;
216
+ const itemType = getListShape(field.type)?.itemType;
217
+ if (!relation || relation.embedded || !isObjectType(itemType)) return null;
218
+ if ((relation.connectionField || fieldName) !== 'version' || !isProtectedType(itemType)) return null;
219
+ return `${collisionPrefix(itemType.name)} ${ownerType.name}.${fieldName} links its items through version; use another connectionField`;
220
+ };
221
+
222
+ /**
223
+ * The INVALID_MODEL message for the first field of the type that is typed RecordVersion (the instance
224
+ * this module exports, single or list, required or not) without being the injected version field, or
225
+ * null. Only the managed field may use the scalar in a type; a registered mutation or custom query may
226
+ * still use it in its own input and output types.
227
+ */
228
+ export const recordVersionFieldMisuse = (gqltype) => {
229
+ for (const [name, field] of Object.entries(gqltype.getFields())) {
230
+ if (getNamedType(field.type) === RecordVersion && !isVersionField(field)) {
231
+ return `${gqltype.name}.${name} uses RecordVersion, which only the managed version field may use`;
232
+ }
233
+ }
234
+ return null;
235
+ };
package/src/runtime.js CHANGED
@@ -33,6 +33,12 @@ import {
33
33
  import {
34
34
  fieldMetadataNamesFixed, fieldMetadataTypes, fixFieldMetadataNames, renameFieldMetadataTypes,
35
35
  } from './introspection.js';
36
+ import {
37
+ MAX_RECORD_VERSION, RecordVersion, RecordVersionRef, assertVersionInput, concurrencyDeclared, createVersionFieldConfig,
38
+ isProtectedType, isVersionField, readStoredVersion, recordVersionFieldMisuse, retainedStoredVersion, unadvancedVersionError,
39
+ unexpectedDeleteError, versionConflictError, versionConnectionCollision, versionEmbeddedMisuse, versionExhaustedError,
40
+ versionFieldCollision, versionManagedError,
41
+ } from './record-version.js';
36
42
 
37
43
  // Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
38
44
  // created is application code, such as a masking resolver, recorded once per type so later
@@ -85,6 +91,33 @@ const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER
85
91
  // Generated inputs never write a readOnly field, nor a virtual one, which virtual implies.
86
92
  const isReadOnlyField = (field) => !!field.extensions?.readOnly || isVirtualField(field);
87
93
 
94
+ // Optimistic concurrency (#98). A type is protected once it carries the version field createSchema()
95
+ // injects, which it keeps for good, so code that builds schemas and runs requests asks this instead of
96
+ // reading the live `extensions.concurrency`.
97
+ const isProtected = (gqltype) => isVersionField(gqltype.getFields().version);
98
+ // Whether each registered type was protected when the first createSchema() that contained it returned,
99
+ // for every runtime of this copy of the core code. A failed createSchema() records nothing.
100
+ const protectionStatus = new WeakMap();
101
+
102
+ const invalidWriteFeatures = (message) => {
103
+ throw new SimfinityError(`DatabaseAdapter writeFeatures ${message}`, 'INVALID_WRITE_FEATURES', 500);
104
+ };
105
+
106
+ // Reads an adapter's `writeFeatures` declaration once, into a frozen copy. Undefined or null declares
107
+ // nothing; a plain object may declare `optimisticConcurrency` as a boolean, and its other members are
108
+ // left for later features. Anything else throws INVALID_WRITE_FEATURES (500).
109
+ const resolveWriteFeatures = (writeFeatures) => {
110
+ if (writeFeatures == null) return Object.freeze({ optimisticConcurrency: false });
111
+ if (typeof writeFeatures !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(writeFeatures))) {
112
+ invalidWriteFeatures('must be a plain object');
113
+ }
114
+ const { optimisticConcurrency } = writeFeatures;
115
+ if (optimisticConcurrency !== undefined && typeof optimisticConcurrency !== 'boolean') {
116
+ invalidWriteFeatures('optimisticConcurrency must be a boolean');
117
+ }
118
+ return Object.freeze({ optimisticConcurrency: optimisticConcurrency === true });
119
+ };
120
+
88
121
  // Query scopes restrict reads, so a scope Simfinity cannot run must fail rather than be skipped.
89
122
  const SCOPE_OPERATIONS = ['find', 'get_by_id', 'aggregate'];
90
123
  const invalidScope = (typeName, detail) => new SimfinityError(
@@ -280,6 +313,24 @@ const assertFieldMetadataNamesFree = (roots) => {
280
313
  if (clash) throw reservedTypeNameError(clash);
281
314
  };
282
315
 
316
+ // A schema with protected types contains RecordVersion, and RecordVersionRef whenever a generated input
317
+ // reaches a collection of protected items, so an application type with either name, other than the
318
+ // instances this module exports, is rejected there (#98). Like the field metadata names, they are first
319
+ // checked before anything is built, reading input objects by their own name only, and then in the whole
320
+ // schema when it is constructed.
321
+ const versionTypeClash = (roots, options) => [RecordVersion, RecordVersionRef]
322
+ .map((own) => findSchemaType(roots, own.name, own, options)).find(Boolean) || null;
323
+ // A scalar named RecordVersion with core's own description is almost always core's scalar from a
324
+ // second copy of this package, which protected types cannot work with.
325
+ const reservedVersionTypeNameError = (clash) => new SimfinityError(
326
+ clash instanceof GraphQLScalarType && clash.name === RecordVersion.name && clash.description === RecordVersion.description
327
+ ? 'RecordVersion is reserved for optimistic concurrency, and this RecordVersion comes from another copy of '
328
+ + '@simtlix/simfinity-core: two copies of the package are loaded; import RecordVersion from the facade the '
329
+ + 'application uses'
330
+ : `${clash.name} is reserved for optimistic concurrency; rename the application type`,
331
+ 'RESERVED_TYPE_NAME', 500,
332
+ );
333
+
283
334
  const buildSchemaConfig = (query, mutation) => {
284
335
  const config = { query };
285
336
  if (Object.keys(mutation.getFields()).length > 0) {
@@ -292,12 +343,15 @@ const buildSchemaConfig = (query, mutation) => {
292
343
  // those are renamed SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended
293
344
  // when the schema also has those names, in every schema the process constructs from then on. A
294
345
  // construction that fails puts the previous metadata types back, so only a schema that exists fixes
295
- // the names.
296
- const constructSchema = (query, mutation) => {
346
+ // the names. With `reserveVersionTypeNames`, for a schema with protected types, an application type
347
+ // named RecordVersion or RecordVersionRef fails, even one that only an input field reaches.
348
+ const constructSchema = (query, mutation, reserveVersionTypeNames = false) => {
297
349
  const config = buildSchemaConfig(query, mutation);
298
350
  const roots = Object.values(config);
299
351
  const clash = fieldMetadataClash(roots);
300
352
  if (clash && fieldMetadataNamesFixed()) throw reservedTypeNameError(clash);
353
+ const versionClash = reserveVersionTypeNames && versionTypeClash(roots);
354
+ if (versionClash) throw reservedVersionTypeNameError(versionClash);
301
355
  let names = null;
302
356
  if (clash) {
303
357
  const taken = (name) => findSchemaType(roots, name) !== null;
@@ -431,6 +485,21 @@ const readOwnField = markGenerated((parent, args, context, info) => {
431
485
  return typeof property === 'function' ? property.call(parent, args, context, info) : property;
432
486
  });
433
487
 
488
+ // Puts the version field into a protected type's getFields() map, after the type's own fields, unless
489
+ // the type carries it already: from an earlier schema, another runtime of this core copy that shares
490
+ // the type, or the type it was copied from. The resolver reads only the parent, so it binds the type to
491
+ // no runtime, and a record without a stored version, such as a MongoDB document written before the
492
+ // type was protected, reads as version 1. An invalid stored version is reported under the type being
493
+ // resolved, which a renamed copy shares the resolver with, or under the type it was injected into.
494
+ const injectVersionField = (gqltype) => {
495
+ if (isProtected(gqltype)) return;
496
+ const typeName = gqltype.name;
497
+ gqltype.getFields().version = createVersionFieldConfig(markGenerated((parent, args, context, info) => readStoredVersion(
498
+ retainedStoredVersion(parent) ?? ownValue(parent, 'version'),
499
+ { typeName: info?.parentType?.name ?? typeName, id: ownValue(parent, '_id') ?? ownValue(parent, 'id') },
500
+ )));
501
+ };
502
+
434
503
  const cloneInput = (value, type) => {
435
504
  if (value === null || value === undefined || !type) return value;
436
505
  if (type instanceof GraphQLNonNull) return cloneInput(value, type.ofType);
@@ -479,6 +548,9 @@ export const createRuntime = (adapter) => {
479
548
  operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness,
480
549
  } = resolveQueryFeatures(adapter.queryFeatures);
481
550
  const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
551
+ // The write features the adapter implements, read once: with optimisticConcurrency, schemas may
552
+ // contain protected types (#98).
553
+ const writeFeatures = resolveWriteFeatures(adapter.writeFeatures);
482
554
 
483
555
  const stateValue = adapter.stateValue || ((state) => state.name);
484
556
  const runtimeIdentity = {};
@@ -494,6 +566,9 @@ export const createRuntime = (adapter) => {
494
566
  const selfCollectionNameHolders = new Map();
495
567
  const qualifiedSelfCollections = new Map();
496
568
  const registeredMutations = {};
569
+ // Models createModel() returned for this runtime, as opposed to models supplied to connect(). A type
570
+ // with one was built for storage without a version field, even by a createSchema() that failed later.
571
+ const generatedModels = new WeakSet();
497
572
  const registeredQueries = {};
498
573
  const registeredViews = {};
499
574
  const middlewares = [];
@@ -556,9 +631,9 @@ export const createRuntime = (adapter) => {
556
631
  }
557
632
  };
558
633
 
559
- // Object and interface types a schema of this runtime can reach from its registered types and
560
- // custom operation results, following the same references as GraphQLSchema.
561
- const collectSchemaOutputTypes = () => {
634
+ // Object and interface types reachable from these roots, following the output references of
635
+ // GraphQLSchema: fields, interfaces and union members. Input objects cannot hold either.
636
+ const collectOutputTypes = (roots) => {
562
637
  const visited = new Set();
563
638
  const outputTypes = [];
564
639
  const visit = (type) => {
@@ -574,14 +649,20 @@ export const createRuntime = (adapter) => {
574
649
  named.getInterfaces().forEach(visit);
575
650
  for (const field of Object.values(named.getFields())) visit(field.type);
576
651
  };
652
+ roots.forEach(visit);
653
+ return outputTypes;
654
+ };
655
+
656
+ // Object and interface types a schema of this runtime can reach from its registered types and
657
+ // custom operation results, following the same references as GraphQLSchema.
658
+ const collectSchemaOutputTypes = () => {
577
659
  const registeredTypes = Object.values(typesDict.types).map(({ gqltype }) => gqltype);
578
660
  const customOperations = [
579
661
  ...Object.values(registeredMutations),
580
662
  ...Object.values(registeredQueries),
581
663
  ];
582
664
  const customOutputTypes = customOperations.map(({ outputModel }) => outputModel);
583
- [...registeredTypes, ...customOutputTypes].forEach(visit);
584
- return outputTypes;
665
+ return collectOutputTypes([...registeredTypes, ...customOutputTypes]);
585
666
  };
586
667
 
587
668
  const hasUnresolvedRelation = (gqltype) => Object.values(gqltype.getFields()).some(
@@ -814,8 +895,10 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
814
895
  + `\`${operation}\` operation in generated inputs, ${omittedOperation[operation]()}`);
815
896
  }
816
897
  }
817
- // Null IDs are skipped, so `deleted` accepts them whatever the collection's item nullability.
818
- fields.deleted = { type: new GraphQLList(GraphQLID) };
898
+ // Null entries are skipped, so `deleted` accepts them whatever the collection's item nullability.
899
+ // A protected item is deleted at the version the client read, whether or not the parent is
900
+ // protected (#98).
901
+ fields.deleted = { type: new GraphQLList(isProtected(itemType) ? RecordVersionRef : GraphQLID) };
819
902
  return fields;
820
903
  },
821
904
  });
@@ -1050,6 +1133,14 @@ const buildInputType = (gqltype, reachable) => {
1050
1133
  description: fieldEntry.description,
1051
1134
  };
1052
1135
  }
1136
+ // The version field is readOnly, so only this adds it, last and required, to a protected type's
1137
+ // update input, which updates, state actions and nested `updated` entries take (#98). Create inputs,
1138
+ // and the `added` items built from them, never get it. An update input that would hold only the
1139
+ // version, with no writable id or other field, is left without fields as before (#161), so the type
1140
+ // has no update mutation, state actions or nested `updated` entries, and keeps its delete mutation.
1141
+ if (isProtected(gqltype) && Object.keys(fieldsArgForUpdate).length > 0) {
1142
+ fieldsArgForUpdate.version = { type: new GraphQLNonNull(RecordVersion), description: argTypes.version.description };
1143
+ }
1053
1144
 
1054
1145
  const inputTypeForAdd = new GraphQLInputObjectType({
1055
1146
  name: `${gqltype.name}Input`,
@@ -1222,8 +1313,9 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
1222
1313
  const collectionFields = {};
1223
1314
 
1224
1315
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
1225
- // A virtual field stores nothing, whatever middleware or saveObject() passes for it.
1226
- if (isVirtualField(fieldEntry)) continue;
1316
+ // A virtual field stores nothing, whatever middleware or saveObject() passes for it, and Simfinity
1317
+ // writes the version field of a protected type itself (#98).
1318
+ if (isVirtualField(fieldEntry) || isVersionField(fieldEntry)) continue;
1227
1319
  const fieldValue = ownValue(args, fieldEntryName);
1228
1320
  if (fieldEntry.extensions && fieldEntry.extensions.validations
1229
1321
  && fieldEntry.extensions.validations[operation]) {
@@ -1339,10 +1431,58 @@ const iterateOnCollectionFields = async (materializedModel, gqltype, objectId, s
1339
1431
  const readByIdIsInert = (Model) => typeof adapter.readByIdIsInert === 'function'
1340
1432
  && adapter.readByIdIsInert(Model) === true;
1341
1433
 
1434
+ // Optimistic concurrency (#98). Every update, state action, delete and nested entry of a protected
1435
+ // type first reads its record whole, plain and locked: the pre-read, which also serves the embedded
1436
+ // merge, the from-state check and onDelete. Its version is normalized before any hook sees it, so a
1437
+ // MongoDB document stored before the type was protected shows 1, as on PostgreSQL.
1438
+ const normalizeProtectedVersion = (record, gqltype, id) => {
1439
+ record.version = readStoredVersion(record.version,
1440
+ { typeName: gqltype.name, id: ownValue(record, '_id') ?? ownValue(record, 'id') ?? id });
1441
+ return record.version;
1442
+ };
1443
+
1444
+ const readProtectedRecord = async (Model, gqltype, id, session) => {
1445
+ const record = await adapter.getById(Model, id, session, { plain: true, lock: true });
1446
+ if (record) normalizeProtectedVersion(record, gqltype, id);
1447
+ return record;
1448
+ };
1449
+
1450
+ // Whether a stale or exhausted version that the pre-read found is reported at once, before the
1451
+ // record's validators, hooks and action run (decision 9): to every caller of a type without a
1452
+ // get_by_id scope, and to a caller who can read the record through that scope. The record is read as
1453
+ // a by-ID read does, in the operation's session and by its stored key, not the client's spelling of
1454
+ // the ID. A scope function that throws counts as hiding the record; errors of the gates and of
1455
+ // adapter.find propagate.
1456
+ const reportEarlyConflict = async (type, record, context, session) => {
1457
+ if (!getScope(type.gqltype, 'get_by_id')) return true;
1458
+ const key = String(record._id ?? record.id);
1459
+ const args = { id: { operator: 'EQ', value: key } };
1460
+ try {
1461
+ return await findVisibleById(type, args, session, context, key) !== null;
1462
+ } catch (error) {
1463
+ if (failedByIdScopes.has(args)) return false;
1464
+ throw error;
1465
+ }
1466
+ };
1467
+
1468
+ // Outcome 3 of the pre-read: the stored version is not the expected one, or an update or a state
1469
+ // action (`advances`) would take it past MAX_RECORD_VERSION; a delete does not advance it. When the
1470
+ // conflict is not reported early, the operation continues exactly as with a matching version, and the
1471
+ // conditional write reports it.
1472
+ const assertRecordVersion = async (gqltype, id, record, expectedVersion, advances, context, session) => {
1473
+ let error = null;
1474
+ if (record.version !== expectedVersion) error = versionConflictError(gqltype.name, id);
1475
+ else if (advances && expectedVersion === MAX_RECORD_VERSION) error = versionExhaustedError(gqltype.name, id);
1476
+ if (error && await reportEarlyConflict(typesDict.types[gqltype.name], record, context, session)) throw error;
1477
+ };
1478
+
1342
1479
  // The record is read for the onDelete hook; adapter.delete casts the ID, deletes and returns the
1343
1480
  // record itself. Without a hook, the read is skipped when it is inert. A nested delete passes the
1344
- // child it has already read as `current`.
1345
- const onDelete = async (Model, controller, id, session, context, current) => {
1481
+ // child it has already read as `current`, and a protected delete its pre-read record, null when it is
1482
+ // missing, with `precondition`: the type and the version the record must still have. The adapter then
1483
+ // deletes it only at that version, and a record it deleted at another one, which an adapter wrapper
1484
+ // that drops the options would delete, fails with DATABASE_ERROR and rolls back with the transaction.
1485
+ const onDelete = async (Model, controller, id, session, context, current, precondition = null) => {
1346
1486
  const hasHook = !!(controller && controller.onDelete);
1347
1487
  let currentObject = current;
1348
1488
  if (currentObject === undefined && (hasHook || !readByIdIsInert(Model))) {
@@ -1353,7 +1493,13 @@ const onDelete = async (Model, controller, id, session, context, current) => {
1353
1493
  await controller.onDelete(currentObject, session, context);
1354
1494
  }
1355
1495
 
1356
- return adapter.delete(Model, id, session);
1496
+ if (!precondition) return adapter.delete(Model, id, session);
1497
+ const { gqltype, expectedVersion } = precondition;
1498
+ const deleted = await adapter.delete(Model, id, session, { expectedVersion });
1499
+ if (deleted && readStoredVersion(deleted.version, { typeName: gqltype.name, id }) !== expectedVersion) {
1500
+ throw unexpectedDeleteError(gqltype.name, id);
1501
+ }
1502
+ return deleted;
1357
1503
  };
1358
1504
 
1359
1505
  const getEmbeddedFieldNames = (gqltype) => {
@@ -1428,7 +1574,7 @@ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
1428
1574
  // onUpdating, so that a malformed ID fails before the hook, and before nested collection items, so
1429
1575
  // that the SQL adapter locks the parent row before child writes; otherwise it skips the read only
1430
1576
  // when it is inert. A state action or a nested update has already read and checked the record
1431
- // (`recordChecked`).
1577
+ // (`recordChecked`). A protected type never reads here: its pre-read record serves the merge (#98).
1432
1578
  const readsStoredEmbedded = (Model, controller, materializedModel, embeddedFieldNames, recordChecked) => {
1433
1579
  const usesStored = embeddedFieldNames.some((name) => {
1434
1580
  const value = ownValue(materializedModel.modelArgs, name);
@@ -1442,17 +1588,29 @@ const readsStoredEmbedded = (Model, controller, materializedModel, embeddedField
1442
1588
  || !readByIdIsInert(Model);
1443
1589
  };
1444
1590
 
1591
+ // Whether an update sets the version field, at the top level or inside an operator such as $set or
1592
+ // $unset. The adapter writes the version of a protected type (#98).
1593
+ const setsVersion = (update) => [update, ...Object.entries(update)
1594
+ .filter(([key]) => key.startsWith('$')).map(([, value]) => value)]
1595
+ .some((target) => target !== null && typeof target === 'object' && Object.hasOwn(target, 'version'));
1596
+
1597
+ // The update of a protected type (#98) takes from its caller the version captured before the
1598
+ // pre-read, `expectedVersion`, the immutable observed version, and the pre-read record (null when
1599
+ // missing). It merges embedded values from that record and writes only at the expected version,
1600
+ // which the adapter advances; onUpdating
1601
+ // cannot set the version, and a result that did not advance it fails with DATABASE_ERROR.
1445
1602
  const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context,
1446
- recordChecked = false) => {
1603
+ recordChecked = false, { expectedVersion, observedVersion, record } = {}) => {
1604
+ const versioned = isProtected(gqltype);
1447
1605
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
1448
1606
  const objectId = args.id;
1449
1607
  const argTypes = gqltype.getFields();
1450
1608
  const embeddedFieldNames = getEmbeddedFieldNames(gqltype);
1451
1609
 
1452
1610
  if (embeddedFieldNames.length > 0
1453
- && readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked)) {
1611
+ && (versioned || readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked))) {
1454
1612
  const projection = Object.fromEntries(embeddedFieldNames.map((name) => [name, 1]));
1455
- const currentObject = await adapter.getById(Model, objectId, session, {
1613
+ const currentObject = versioned ? record : await adapter.getById(Model, objectId, session, {
1456
1614
  projection,
1457
1615
  plain: true,
1458
1616
  lock: true,
@@ -1477,7 +1635,8 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1477
1635
  }
1478
1636
 
1479
1637
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
1480
- if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)) {
1638
+ if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)
1639
+ && !isVersionField(fieldEntry)) {
1481
1640
  materializedModel.modelArgs.$unset = {
1482
1641
  ...materializedModel.modelArgs.$unset,
1483
1642
  [getFieldStorageName(fieldEntryName, fieldEntry)]: '',
@@ -1492,11 +1651,24 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1492
1651
 
1493
1652
  if (controller && controller.onUpdating) {
1494
1653
  await controller.onUpdating(objectId, update, session, context);
1654
+ if (versioned && setsVersion(update)) throw versionManagedError(gqltype.name, 'onUpdating');
1495
1655
  }
1496
1656
 
1497
1657
  if (linkToParent) linkToParent(update);
1498
- const result = await adapter.update(Model, objectId, update, session);
1658
+ const result = versioned
1659
+ ? await adapter.update(Model, objectId, update, session, { expectedVersion })
1660
+ : await adapter.update(Model, objectId, update, session);
1499
1661
  if (!result) throw new SimfinityError(`${gqltype.name} ${objectId} is not valid`, 'NOT_VALID_ID', 404);
1662
+ // Diagnose a result that did not advance beyond the pre-read, including a dropped precondition
1663
+ // when the caller sent one revision less. This cannot prove arbitrary wrappers honored the atomic
1664
+ // condition: they must still forward it. The observation is captured before application code runs.
1665
+ if (versioned) {
1666
+ const returnedVersion = readStoredVersion(result.version, { typeName: gqltype.name, id: objectId });
1667
+ if (returnedVersion !== expectedVersion + 1
1668
+ || (observedVersion !== undefined && returnedVersion <= observedVersion)) {
1669
+ throw unadvancedVersionError(gqltype.name, objectId);
1670
+ }
1671
+ }
1500
1672
 
1501
1673
  if (materializedModel.collectionFields) {
1502
1674
  // Children link to, and are owned through, the parent's stored key, as when a new parent is
@@ -1513,18 +1685,27 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1513
1685
  return result;
1514
1686
  };
1515
1687
 
1688
+ // A protected type's version is captured before the action can change `args`, and checked on the
1689
+ // plain pre-read record before the from-state check, which reads `state` from that record (#98).
1516
1690
  const onStateChanged = async (Model, gqltype, controller, args, session, actionField, context) => {
1517
- const storedModel = await adapter.getById(Model, args.id, session, { lock: true });
1691
+ const versioned = isProtected(gqltype);
1692
+ const expectedVersion = versioned ? assertVersionInput(args.version) : undefined;
1693
+ const storedModel = versioned
1694
+ ? await readProtectedRecord(Model, gqltype, args.id, session)
1695
+ : await adapter.getById(Model, args.id, session, { lock: true });
1696
+ const observedVersion = versioned ? storedModel?.version : undefined;
1518
1697
  if (!storedModel) {
1519
1698
  throw new SimfinityError(`${gqltype.name} ${args.id} is not valid`, 'NOT_VALID_ID', 404);
1520
1699
  }
1700
+ if (versioned) await assertRecordVersion(gqltype, args.id, storedModel, expectedVersion, true, context, session);
1521
1701
  if (storedModel.state === stateValue(actionField.from)) {
1522
1702
  if (actionField.action) {
1523
1703
  await actionField.action(args, session);
1524
1704
  }
1525
1705
 
1526
1706
  args.state = stateValue(actionField.to);
1527
- let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context, true);
1707
+ let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context, true,
1708
+ versioned ? { expectedVersion, observedVersion, record: storedModel } : undefined);
1528
1709
  result = adapter.toObject(result);
1529
1710
  result.state = actionField.to.value;
1530
1711
  return result;
@@ -1539,11 +1720,15 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
1539
1720
  typesDict.types[gqltype.name].stateMachine.initialState,
1540
1721
  );
1541
1722
  }
1723
+ // A protected record starts at version 1, whatever saveObject() or middleware supplied (#98).
1724
+ const versioned = isProtected(gqltype);
1725
+ if (versioned) materializedModel.modelArgs.version = 1;
1542
1726
 
1543
1727
  const newObject = adapter.newRecord(Model, materializedModel.modelArgs, session);
1544
1728
 
1545
1729
  if (controller && controller.onSaving) {
1546
1730
  await controller.onSaving(newObject, args, session, context);
1731
+ if (versioned && newObject.version !== 1) throw versionManagedError(gqltype.name, 'onSaving');
1547
1732
  }
1548
1733
 
1549
1734
  if (linkToParent) linkToParent(newObject);
@@ -1635,12 +1820,30 @@ const assertCollectionWrites = (gqltype, input) => {
1635
1820
  }
1636
1821
  };
1637
1822
 
1823
+ // The capture and the pre-read of a protected top-level update or delete (#98): the version the
1824
+ // client sent is checked before anything is read, then against the pre-read record. A missing record
1825
+ // is not reported here; the update fails at its write with NOT_VALID_ID and the delete returns null,
1826
+ // as for an unprotected type.
1827
+ const preReadTopLevel = async (Model, gqltype, { id, version }, advances, context, session) => {
1828
+ const expectedVersion = assertVersionInput(version);
1829
+ const record = await readProtectedRecord(Model, gqltype, id, session);
1830
+ const observedVersion = record?.version;
1831
+ if (record) await assertRecordVersion(gqltype, id, record, expectedVersion, advances, context, session);
1832
+ return { expectedVersion, observedVersion, record };
1833
+ };
1834
+
1835
+ // A delete passes the ID, or { id, version } for a protected type, which each attempt copies; every
1836
+ // other operation passes its input, which attemptInputs copies. Either way, a retry checks the version
1837
+ // the request sent.
1638
1838
  const executeOperation = (Model, gqltype, controller, args, operation, actionField,
1639
1839
  session, context, inputType) => {
1640
1840
  const limit = getMaxNestedOperations();
1641
1841
  if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
1642
1842
  if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
1643
- const nextInput = operation === operations.DELETE ? () => args : attemptInputs(args, inputType);
1843
+ const versioned = isProtected(gqltype);
1844
+ let nextInput = () => args;
1845
+ if (operation !== operations.DELETE) nextInput = attemptInputs(args, inputType);
1846
+ else if (versioned) nextInput = () => ({ ...args });
1644
1847
  return adapter.withTransaction(
1645
1848
  session,
1646
1849
  async (mySession) => {
@@ -1648,10 +1851,17 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
1648
1851
  switch (operation) {
1649
1852
  case operations.SAVE:
1650
1853
  return onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
1651
- case operations.UPDATE:
1652
- return onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context);
1653
- case operations.DELETE:
1654
- return onDelete(Model, controller, attemptArgs, mySession, context);
1854
+ case operations.UPDATE: {
1855
+ const precondition = versioned
1856
+ ? await preReadTopLevel(Model, gqltype, attemptArgs, true, context, mySession) : undefined;
1857
+ return onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context, false, precondition);
1858
+ }
1859
+ case operations.DELETE: {
1860
+ if (!versioned) return onDelete(Model, controller, attemptArgs, mySession, context);
1861
+ const { expectedVersion, record } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
1862
+ mySession);
1863
+ return onDelete(Model, controller, attemptArgs.id, mySession, context, record, { gqltype, expectedVersion });
1864
+ }
1655
1865
  case operations.STATE_CHANGED:
1656
1866
  return onStateChanged(Model, gqltype, controller,
1657
1867
  attemptArgs, mySession, actionField, context);
@@ -1684,14 +1894,28 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1684
1894
  if (item.$set) delete item.$set[connection.storageFieldName];
1685
1895
  };
1686
1896
 
1897
+ // A protected child is updated or deleted at the version its entry carries (#98). A deleted entry is
1898
+ // { id, version } for item middleware; one that is not an object, such as a string ID, has no version.
1899
+ const versioned = isProtected(collectionGQLType) && operationType !== operations.SAVE;
1687
1900
  for (const element of collectionFieldsList) {
1688
1901
  if (element === null) continue;
1689
- const args = operationType === operations.DELETE ? { id: element } : { input: element };
1902
+ let args = { input: element };
1903
+ if (operationType === operations.DELETE) {
1904
+ args = versioned ? { id: element?.id, version: element?.version } : { id: element };
1905
+ }
1690
1906
  await executeMiddleware({ type, args, operation: operationType, context });
1691
1907
 
1908
+ // Captured after the item's middleware, before the child is read.
1909
+ let expectedVersion;
1910
+ if (versioned) {
1911
+ expectedVersion = assertVersionInput(operationType === operations.DELETE ? args.version : args.input.version);
1912
+ }
1913
+
1692
1914
  // The ownership check reads the child (the SQL adapter locks its row). onDelete receives this
1693
- // record, and an update reads the child again only to merge embedded values.
1915
+ // record, and an update reads the child again only to merge embedded values, unless the child is
1916
+ // protected: then this is its pre-read, whose version is checked after ownership.
1694
1917
  let child;
1918
+ let observedVersion;
1695
1919
  if (operationType === operations.UPDATE || operationType === operations.DELETE) {
1696
1920
  const id = operationType === operations.DELETE ? args.id : args.input.id;
1697
1921
  child = await adapter.getById(type.model, id, session, { plain: true, lock: true });
@@ -1701,6 +1925,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1701
1925
  if (String(child[connection.storageFieldName]) !== String(parentKey)) {
1702
1926
  throw new ForbiddenError('Child does not belong to this parent');
1703
1927
  }
1928
+ if (versioned) {
1929
+ observedVersion = normalizeProtectedVersion(child, collectionGQLType, id);
1930
+ await assertRecordVersion(collectionGQLType, id, child, expectedVersion, operationType === operations.UPDATE,
1931
+ context, session);
1932
+ }
1704
1933
  }
1705
1934
 
1706
1935
  switch (operationType) {
@@ -1710,10 +1939,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1710
1939
  break;
1711
1940
  case operations.UPDATE:
1712
1941
  await onUpdateSubject(type.model, collectionGQLType, type.controller,
1713
- args.input, session, linkToParent, context, true);
1942
+ args.input, session, linkToParent, context, true, versioned ? { expectedVersion, observedVersion, record: child } : undefined);
1714
1943
  break;
1715
1944
  case operations.DELETE:
1716
- await onDelete(type.model, type.controller, args.id, session, context, child);
1945
+ await onDelete(type.model, type.controller, args.id, session, context, child,
1946
+ versioned ? { gqltype: collectionGQLType, expectedVersion } : null);
1717
1947
  }
1718
1948
  }
1719
1949
  };
@@ -2304,6 +2534,29 @@ const loadReference = (type, id, requiredId, context) => {
2304
2534
  });
2305
2535
  };
2306
2536
 
2537
+ // The query arguments whose get_by_id scope function threw in findVisibleById. The error propagates
2538
+ // as it was thrown; the conflict path (reportEarlyConflict) tells it from errors of the gates and of
2539
+ // adapter.find this way. Each caller (resolveById and reportEarlyConflict) allocates fresh args
2540
+ // and calls findVisibleById exactly once with that object; never reuse marked args for another read.
2541
+ const failedByIdScopes = new WeakSet();
2542
+
2543
+ // The record that the type's get_by_id scope shows the caller, or null. `args` holds an `id` EQ
2544
+ // condition, to which the scope adds its own; the gates check them as for every read, and adapter.find
2545
+ // reads in `session`, pinned to `requiredId`. get_by_id middleware is not run here; resolveById runs it
2546
+ // first.
2547
+ const findVisibleById = async (type, args, session, context, requiredId) => {
2548
+ try {
2549
+ await executeScope({ type, args, operation: 'get_by_id', context });
2550
+ } catch (error) {
2551
+ failedByIdScopes.add(args);
2552
+ throw error;
2553
+ }
2554
+ assertStoredQueryPaths(type.gqltype, args, 'find');
2555
+ assertQueryConditions(type.gqltype, args, 'find');
2556
+ const results = await adapter.find(type.model, type.gqltype, args, session, { requiredId, context });
2557
+ return results[0] || null;
2558
+ };
2559
+
2307
2560
  // `requiredId` marks a generated single-reference read. Middleware still runs per call; then, when the
2308
2561
  // adapter reads by ID in batches, and unless the type has a get_by_id scope or middleware changed the
2309
2562
  // ID, the read joins the request's batch.
@@ -2317,12 +2570,7 @@ const resolveById = async (type, args, context, requiredId) => {
2317
2570
  }
2318
2571
  return adapter.getById(type.model, args.id, null, { requiredId, context });
2319
2572
  }
2320
- const queryArgs = { id: { operator: 'EQ', value: args.id } };
2321
- await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
2322
- assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
2323
- assertQueryConditions(type.gqltype, queryArgs, 'find');
2324
- const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
2325
- return results[0] || null;
2573
+ return findVisibleById(type, { id: { operator: 'EQ', value: args.id } }, null, context, requiredId);
2326
2574
  };
2327
2575
 
2328
2576
  // Batched collection reads. A generated collection field reads its children with one
@@ -2713,10 +2961,13 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2713
2961
  : `mutation that takes no input or an input of its own, since getInputType(${type.gqltype.name}) `
2714
2962
  + 'has no fields.'));
2715
2963
  }
2964
+ // A protected type is deleted at the version the client read (#98).
2965
+ const deleteArgs = { id: { type: new GraphQLNonNull(GraphQLID) } };
2966
+ if (isProtected(type.gqltype)) deleteArgs.version = { type: new GraphQLNonNull(RecordVersion) };
2716
2967
  rootQueryArgs.fields[`delete${type.simpleEntityEndpointName}`] = {
2717
2968
  type: type.gqltype,
2718
2969
  description: 'delete',
2719
- args: { id: { type: new GraphQLNonNull(GraphQLID) } },
2970
+ args: deleteArgs,
2720
2971
  extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.DELETE } },
2721
2972
  async resolve(parent, args, context) {
2722
2973
  const params = {
@@ -2727,8 +2978,10 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2727
2978
  };
2728
2979
 
2729
2980
  await executeMiddleware(params);
2981
+ // A protected delete takes the version as middleware left it.
2982
+ const target = isProtected(type.gqltype) ? { id: args.id, version: args.version } : args.id;
2730
2983
  return executeOperation(type.model, type.gqltype, type.controller,
2731
- args.id, operations.DELETE, null, null, context, null);
2984
+ target, operations.DELETE, null, null, context, null);
2732
2985
  },
2733
2986
  };
2734
2987
  }
@@ -3317,6 +3570,108 @@ const warnUndeclaredVirtualFields = () => {
3317
3570
  }
3318
3571
  };
3319
3572
 
3573
+ // The types a model stores: the registered types and the embedded types they reach at any depth,
3574
+ // readOnly fields included, as assertPlainConnectionFields visits them.
3575
+ const collectStoredTypes = () => {
3576
+ const stored = new Set();
3577
+ const visit = (gqltype) => {
3578
+ if (stored.has(gqltype)) return;
3579
+ stored.add(gqltype);
3580
+ for (const field of Object.values(gqltype.getFields())) {
3581
+ const named = field.extensions?.relation?.embedded && getNamedType(field.type);
3582
+ if (named instanceof GraphQLObjectType) visit(named);
3583
+ }
3584
+ };
3585
+ for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
3586
+ return [...stored];
3587
+ };
3588
+
3589
+ const invalidModel = (message) => new SimfinityError(message, 'INVALID_MODEL', 400);
3590
+
3591
+ // Optimistic concurrency (#98), declared per type with `extensions.concurrency`. These checks run in
3592
+ // this order, after stored identities are known and before adapter.prepare(), so a rejected schema
3593
+ // changes nothing. `applicationTypes` are the roots of the schema's types. Returns the registrations of
3594
+ // the protected types and whether the included roots reserve the version names.
3595
+ const assertConcurrencyDeclarations = (applicationTypes) => {
3596
+ const registrations = Object.values(typesDict.types);
3597
+ // The shape of every declaration.
3598
+ const protectedTypes = registrations.filter(({ gqltype }) => concurrencyDeclared(gqltype));
3599
+ // Generated inputs, models and stored data follow the protection a type had when the first schema
3600
+ // containing it was created, and an injected field is never removed, so neither can change later.
3601
+ // Inputs or a model that an earlier createSchema() built without the field also fix protection,
3602
+ // even if the call failed afterwards; a model supplied to connect() does not.
3603
+ for (const registration of registrations) {
3604
+ const { gqltype } = registration;
3605
+ const declared = protectedTypes.includes(registration);
3606
+ const builtWithoutField = !isProtected(gqltype)
3607
+ && (!!registration.inputType || generatedModels.has(registration.model));
3608
+ if (declared && protectionStatus.get(gqltype) === false) {
3609
+ throw invalidModel(`${gqltype.name} declares optimistic concurrency after a schema was created without it; `
3610
+ + `declare it before the first createSchema() that contains ${gqltype.name}`);
3611
+ }
3612
+ if (declared && builtWithoutField) {
3613
+ throw invalidModel(`${gqltype.name} declares optimistic concurrency after its inputs or generated model were built without it; `
3614
+ + `declare it before the first createSchema() attempt that builds ${gqltype.name}`);
3615
+ }
3616
+ if (!declared && isProtected(gqltype)) {
3617
+ throw invalidModel(`${gqltype.name} no longer declares optimistic concurrency, but Simfinity already added its `
3618
+ + 'version field; keep extensions.concurrency on a type once it has that field');
3619
+ }
3620
+ }
3621
+ // Another runtime that registers a protected type injects its field when that runtime creates a
3622
+ // schema, which would change a schema already built here, so this schema must register every
3623
+ // protected type it contains, whatever the adapter supports.
3624
+ const reachedTypes = collectOutputTypes(applicationTypes);
3625
+ for (const type of reachedTypes) {
3626
+ if (type instanceof GraphQLObjectType && typesDict.types[type.name]?.gqltype !== type && isProtectedType(type)) {
3627
+ throw invalidModel(`${type.name} declares optimistic concurrency, so a schema that contains it must register it `
3628
+ + 'in this runtime, with connect() or as a relation target');
3629
+ }
3630
+ }
3631
+ // An embedded value's changes belong to the revision of the record that embeds it, wherever a model
3632
+ // stores it.
3633
+ const storedTypes = collectStoredTypes();
3634
+ const storedFields = storedTypes.flatMap((gqltype) => Object.entries(gqltype.getFields())
3635
+ .map(([fieldName, field]) => ({ gqltype, fieldName, field })));
3636
+ for (const { gqltype, fieldName, field } of storedFields) {
3637
+ const named = field.extensions?.relation?.embedded && getNamedType(field.type);
3638
+ if (named instanceof GraphQLObjectType && isProtectedType(named)) {
3639
+ throw invalidModel(versionEmbeddedMisuse(named.name, `${gqltype.name}.${fieldName}`));
3640
+ }
3641
+ }
3642
+ // Only an entity has a record of its own whose version can advance.
3643
+ const nonEntity = protectedTypes.find(({ storedIdentity }) => storedIdentity !== true);
3644
+ if (nonEntity) {
3645
+ throw invalidModel(`${nonEntity.gqltype.name} declares optimistic concurrency, so it must be an entity: register it `
3646
+ + 'with connect() or make it a relation target');
3647
+ }
3648
+ // Nothing else may be stored, or link records, under the version field's name.
3649
+ for (const { gqltype } of protectedTypes) {
3650
+ const collision = versionFieldCollision(gqltype);
3651
+ if (collision) throw invalidModel(collision);
3652
+ }
3653
+ for (const { gqltype, fieldName, field } of storedFields) {
3654
+ const collision = versionConnectionCollision(gqltype, fieldName, field);
3655
+ if (collision) throw invalidModel(collision);
3656
+ }
3657
+ // RecordVersion types managed version fields only, with or without protected types; registered
3658
+ // operations may still use it in their own input and output types.
3659
+ for (const gqltype of storedTypes) {
3660
+ const misuse = recordVersionFieldMisuse(gqltype);
3661
+ if (misuse) throw invalidModel(misuse);
3662
+ }
3663
+ const reserveVersionNames = protectedTypes.some(({ gqltype }) => reachedTypes.includes(gqltype));
3664
+ if (protectedTypes.length && !writeFeatures.optimisticConcurrency) {
3665
+ throw invalidModel(`The database adapter does not support optimistic concurrency, which ${protectedTypes[0].gqltype.name} `
3666
+ + 'declares (extensions.concurrency)');
3667
+ }
3668
+ // A clash that only generated types or the fields of an input object reach is found when the schema
3669
+ // is constructed.
3670
+ const clash = reserveVersionNames && versionTypeClash(applicationTypes, { inputFields: false });
3671
+ if (clash) throw reservedVersionTypeNameError(clash);
3672
+ return { protectedTypes, reserveVersionNames };
3673
+ };
3674
+
3320
3675
  const getRegistrations = () => Object.values(typesDict.types);
3321
3676
 
3322
3677
  const includedOperationTypes = (registrations, includedOperations) => Object.entries(registrations)
@@ -3324,7 +3679,8 @@ const includedOperationTypes = (registrations, includedOperations) => Object.ent
3324
3679
  .flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean));
3325
3680
 
3326
3681
  // Application types that a schema with these allowlists contains, whatever else it reaches: the
3327
- // endpoint types of its queries and mutations, and the input and output types of its custom operations.
3682
+ // endpoint types of its queries and mutations, the input and output types of its custom operations,
3683
+ // and the scalar/enum parameters of included named views.
3328
3684
  const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations, includedCustomQueries) => [
3329
3685
  ...Object.values(typesDict.types)
3330
3686
  .filter((type) => type.endpoint
@@ -3333,6 +3689,9 @@ const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, inc
3333
3689
  .map((type) => type.gqltype),
3334
3690
  ...includedOperationTypes(registeredMutations, includedCustomMutations),
3335
3691
  ...includedOperationTypes(registeredQueries, includedCustomQueries),
3692
+ ...Object.values(registeredViews)
3693
+ .filter(({ type }) => !shouldNotBeIncludedInSchema(includedQueryTypes, type))
3694
+ .flatMap(({ params }) => Object.values(params).map(({ type }) => getNamedType(type))),
3336
3695
  ];
3337
3696
 
3338
3697
  const queryNameConflict = (name) => new SimfinityError(
@@ -3374,7 +3733,9 @@ const assertViewsAvailable = (includedQueryTypes, includedCustomQueries) => {
3374
3733
  if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(paramName) || paramName.startsWith('__')) {
3375
3734
  throw invalidView(`${name} param ${paramName} requires a valid GraphQL name that does not start with __`);
3376
3735
  }
3377
- if (Object.hasOwn(fields, paramName)) {
3736
+ // A protected type's managed version is injected later in createSchema(), but its name is
3737
+ // already unavailable to view params on the first schema, as on every subsequent schema.
3738
+ if (Object.hasOwn(fields, paramName) || (paramName === 'version' && isProtectedType(type))) {
3378
3739
  throw invalidView(`${name} param ${paramName} has the name of a field of ${type.name}`);
3379
3740
  }
3380
3741
  if (LIST_QUERY_ARGS.includes(paramName)) {
@@ -3428,16 +3789,21 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3428
3789
  // An application type with the name of a field metadata type that an earlier schema fixed; one that
3429
3790
  // only generated types or the fields of an input object reach is found when the schema is
3430
3791
  // constructed.
3431
- assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes,
3432
- includedCustomMutations, includedCustomQueries));
3792
+ const applicationTypes = includedApplicationTypes(includedQueryTypes, includedMutationTypes,
3793
+ includedCustomMutations, includedCustomQueries);
3794
+ assertFieldMetadataNamesFree(applicationTypes);
3433
3795
  const referencedTypes = markReferencedTypesForModelGeneration();
3434
3796
  // Entities, which keep their own stored identity: connect() types and relation targets.
3435
3797
  Object.values(typesDict.types).forEach((typeInfo) => {
3436
3798
  typeInfo.storedIdentity = typeInfo.endpoint === true || referencedTypes.has(typeInfo);
3437
3799
  });
3800
+ const { protectedTypes, reserveVersionNames } = assertConcurrencyDeclarations(applicationTypes);
3438
3801
  if (adapter.prepare) {
3439
3802
  adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
3440
3803
  }
3804
+ // prepare() describes protected types from their declaration. Models, inputs, resolvers and query
3805
+ // arguments are built from here on, so they all see the field.
3806
+ protectedTypes.forEach(({ gqltype }) => injectVersionField(gqltype));
3441
3807
 
3442
3808
  Object.values(typesDict.types).forEach((typeInfo) => {
3443
3809
  if (typeInfo.gqltype && !typeInfo.model) {
@@ -3448,6 +3814,8 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3448
3814
  } else if (typeInfo.needsModel) {
3449
3815
  typeInfo.model = adapter.createModel(typeInfo.gqltype, null, { createCollection: false });
3450
3816
  }
3817
+ const { model } = typeInfo;
3818
+ if (model !== null && (typeof model === 'object' || typeof model === 'function')) generatedModels.add(model);
3451
3819
  }
3452
3820
  });
3453
3821
 
@@ -3492,7 +3860,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3492
3860
  includedCustomQueries);
3493
3861
  }
3494
3862
 
3495
- const schema = constructSchema(query, mutation);
3863
+ const schema = constructSchema(query, mutation, reserveVersionNames);
3496
3864
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
3497
3865
  // custom operation result, so another runtime cannot later generate resolvers this schema executes.
3498
3866
  schemaTypes.forEach((type) => {
@@ -3500,6 +3868,10 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3500
3868
  runtimeBoundTypes.set(type, runtimeIdentity);
3501
3869
  }
3502
3870
  });
3871
+ // The schema exists, so each registered type's protection is fixed from now on.
3872
+ Object.values(typesDict.types).forEach(({ gqltype }) => {
3873
+ if (!protectionStatus.has(gqltype)) protectionStatus.set(gqltype, isProtected(gqltype));
3874
+ });
3503
3875
  return schema;
3504
3876
  };
3505
3877
 
@@ -3721,6 +4093,8 @@ const connect = (model, gqltype, simpleEntityEndpointName,
3721
4093
  };
3722
4094
  assertNotBoundElsewhere(gqltype);
3723
4095
  validateScope(gqltype);
4096
+ // The declaration's shape only; it reads extensions, never the fields (#98).
4097
+ concurrencyDeclared(gqltype);
3724
4098
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
3725
4099
 
3726
4100
  invalidateCollectionInputs(gqltype);
@@ -3751,6 +4125,7 @@ const addNoEndpointType = (gqltype) => {
3751
4125
  gqltype, endpoint: false, model: null, needsModel,
3752
4126
  };
3753
4127
  validateScope(gqltype);
4128
+ concurrencyDeclared(gqltype);
3754
4129
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
3755
4130
 
3756
4131
  invalidateCollectionInputs(gqltype);
@@ -3764,7 +4139,9 @@ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
3764
4139
 
3765
4140
  // `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
3766
4141
  // A field named like a generated argument gets no top-level filter either; it is reported once.
3767
- // A virtual field is not part of the query language, so it gets no filter at all.
4142
+ // A virtual field is not part of the query language, so it gets no filter at all, and neither does the
4143
+ // version field of a protected type (#98): a MongoDB record without a stored version reads as 1, which
4144
+ // a filter could not match.
3768
4145
  const createArgsForQuery = (gqltype, omit = null) => {
3769
4146
  const argsObject = {};
3770
4147
  const argTypes = gqltype.getFields();
@@ -3777,7 +4154,7 @@ const createArgsForQuery = (gqltype, omit = null) => {
3777
4154
  }
3778
4155
 
3779
4156
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
3780
- if (fieldEntryName === omit || isVirtualField(fieldEntry)) continue;
4157
+ if (fieldEntryName === omit || isVirtualField(fieldEntry) || isVersionField(fieldEntry)) continue;
3781
4158
  let filterType = null;
3782
4159
 
3783
4160
  if (fieldEntry.type instanceof GraphQLScalarType
package/types/index.d.ts CHANGED
@@ -15,6 +15,20 @@ export const QLOperator: GraphQLEnumType;
15
15
  export const QLSort: GraphQLInputObjectType;
16
16
  export const QLValue: GraphQLScalarType<unknown, unknown>;
17
17
 
18
+ /**
19
+ * The scalar of the `version` field that Simfinity adds to a type that declares optimistic
20
+ * concurrency (see {@link ConcurrencyDeclaration}). It holds safe integers from 1 to
21
+ * {@link MAX_RECORD_VERSION}, a JSON number on the wire: a string, a float, 0, a negative number and an
22
+ * unsafe integer fail GraphQL coercion. A client sends the version it read back unchanged in updates
23
+ * and deletes, and Simfinity advances it. Only the managed field may use it in a type, which
24
+ * `createSchema()` and `describeModels()` enforce with `INVALID_MODEL` (400); a registered mutation or
25
+ * custom query may use it in its own input and output types. Import it from the facade the application
26
+ * uses: the protection depends on one copy of core per process.
27
+ */
28
+ export const RecordVersion: GraphQLScalarType;
29
+ /** The highest version, `Number.MAX_SAFE_INTEGER`. The version of a record never goes past it. */
30
+ export const MAX_RECORD_VERSION: number;
31
+
18
32
  /**
19
33
  * A `QLOperator` value. The operators of 3.7.0 are `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`,
20
34
  * `NIN` and `LIKE`. The literal text operators take a non-null string without U+0000 on a `String`
@@ -99,7 +113,8 @@ export interface FieldDescription {
99
113
  itemRequired: boolean;
100
114
  unique: boolean;
101
115
  readOnly: boolean;
102
- scalar?: 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Enum';
116
+ /** `'Version'` is the managed version field of a type that declares optimistic concurrency (`version: true`). */
117
+ scalar?: 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Enum' | 'Version';
103
118
  values?: string[];
104
119
  enumValues?: { name: string; value: unknown }[];
105
120
  target?: string;
@@ -107,6 +122,12 @@ export interface FieldDescription {
107
122
  fields?: FieldDescription[];
108
123
  private?: boolean;
109
124
  inferred?: boolean;
125
+ /**
126
+ * Set on the managed version field of a type that declares optimistic concurrency, which
127
+ * `describeModels()` appends after the type's own fields: `{ name: 'version', scalar: 'Version',
128
+ * required: true, readOnly: true, version: true }`.
129
+ */
130
+ version?: true;
110
131
  }
111
132
  export interface ModelDescription {
112
133
  entities: Array<{
@@ -121,6 +142,13 @@ export interface ModelDescription {
121
142
  * fields are left out of every entity and embedded type, and the types only they reach are not
122
143
  * described. Throws `INVALID_MODEL` (400) for an invalid `extensions.virtual` declaration, as
123
144
  * `createSchema()` does (see {@link VirtualFieldOptions}).
145
+ *
146
+ * A type that declares optimistic concurrency (see {@link ConcurrencyDeclaration}) is described with
147
+ * its version field last, before `createSchema()` has added it and after, and the call throws
148
+ * `INVALID_MODEL` (400) for an invalid declaration, for a field named `version` that Simfinity did not
149
+ * add, for a reference stored under `version` (`connectionField: 'version'`), for a collection of
150
+ * such items that links through `version`, for such a type used as an embedded value, and for any
151
+ * other field typed {@link RecordVersion}.
124
152
  */
125
153
  export function describeModels(registrations: ModelRegistration[]): ModelDescription;
126
154
  /**
@@ -312,6 +340,19 @@ export type ScopeFunction<Model = any, Session = any> = (params: ScopeParams<Mod
312
340
  * was changed.
313
341
  */
314
342
  export type TypeScopes<Model = any, Session = any> = Partial<Record<ScopeOperation, ScopeFunction<Model, Session>>>;
343
+ /**
344
+ * Value of a type's `extensions.concurrency`: the entity is protected by optimistic concurrency.
345
+ * Simfinity adds a read-only `version: RecordVersion!` field to the type, which starts at 1 and grows
346
+ * by 1 with each accepted write; a client sends the version it read back in updates and deletes, and a
347
+ * write at another version fails with `VERSION_CONFLICT` (409). The only valid value is
348
+ * `{ mode: 'optimistic' }`: `null`, an array, another mode or an extra key throws `INVALID_MODEL`
349
+ * (400). So does a type that is not an entity, one used as an embedded value, one that already has a
350
+ * field named `version` (or a field stored under that name), and an adapter that does not declare
351
+ * `writeFeatures.optimisticConcurrency`.
352
+ */
353
+ export interface ConcurrencyDeclaration {
354
+ mode: 'optimistic';
355
+ }
315
356
 
316
357
  /**
317
358
  * Process-wide limits for generated add, update and state-action mutations. Any other option,
@@ -501,6 +542,15 @@ export interface DatabaseAdapter<Model = any, Session = any> {
501
542
  * collections also needs `referenceEmptiness: true`, and fails with `UNSUPPORTED_QUERY` without it.
502
543
  */
503
544
  queryFeatures?: QueryFeatures | null | undefined;
545
+ /**
546
+ * Optional. Declares the write features this adapter implements. `createRuntime` reads it once and
547
+ * throws `INVALID_WRITE_FEATURES` (500) unless it is undefined, null or an object whose
548
+ * `optimisticConcurrency`, if present, is a boolean. A schema with a type that declares
549
+ * `extensions.concurrency` (see {@link ConcurrencyDeclaration}) throws `INVALID_MODEL` (400) unless
550
+ * the adapter declares `optimisticConcurrency: true`, which commits it to the `options.expectedVersion`
551
+ * contract of `update` and `delete` and to the requirements on `find` below.
552
+ */
553
+ writeFeatures?: { optimisticConcurrency?: boolean } | null | undefined;
504
554
  bind?(runtime: Pick<Runtime<Model, Session>, 'getModel' | 'getType' | 'getRegistrations'>): void;
505
555
  validateRegistration?(registration: RuntimeRegistration<Model, Session>): void;
506
556
  /**
@@ -509,10 +559,16 @@ export interface DatabaseAdapter<Model = any, Session = any> {
509
559
  * must never be stored: an adapter that builds storage from a type's fields skips each field for
510
560
  * which {@link isVirtualField} returns true, on the types its embedded fields reach as well, or
511
561
  * builds it from `describeModels(registrations)`, which leaves them out. Generated writes, filters,
512
- * sorts and aggregations never name a virtual field; a controller hook can still assign one.
562
+ * sorts and aggregations never name a virtual field; a controller hook can still assign one. The
563
+ * version field of a type that declares optimistic concurrency is injected after `prepare()`, so a
564
+ * type here may not have it yet; `describeModels(registrations)` describes it either way.
513
565
  */
514
566
  prepare?(registrations: RuntimeRegistration<Model, Session>[], options: { createCollection: boolean }): void;
515
- /** Creates the model of a registered type. Its fields include virtual fields; see `prepare()`. */
567
+ /**
568
+ * Creates the model of a registered type. Its fields include virtual fields; see `prepare()`. A type
569
+ * that declares optimistic concurrency already has its injected version field here, which
570
+ * `describeModels(registrations)` describes as well.
571
+ */
516
572
  createModel(type: GraphQLObjectType, callback: ((model: Model) => void) | null | undefined, options: { createCollection: boolean }): Model;
517
573
  /**
518
574
  * Casts a reference or batch key to the stored identifier type. Throw a `SimfinityError` with
@@ -574,8 +630,31 @@ export interface DatabaseAdapter<Model = any, Session = any> {
574
630
  */
575
631
  readByIdIsInert?(model: Model): boolean;
576
632
  prepareUpdate(set: Record<string, any>, unset: Record<string, string>): any;
577
- update(model: Model, id: any, changes: any, session?: Session): any;
578
- delete(model: Model, id: any, session?: Session): any;
633
+ /**
634
+ * With `options.expectedVersion`, one atomic write that matches the ID and a stored version equal to
635
+ * it (on MongoDB a missing or null stored version counts as 1), applies `changes`, sets the version
636
+ * to `expectedVersion + 1` and returns the updated record. When nothing matches, or when
637
+ * `expectedVersion >= MAX_RECORD_VERSION` and the write is skipped, it reads the record by ID in the
638
+ * same session: it returns null for a missing one, throws `VERSION_CONFLICT` (409) when the stored
639
+ * version differs, `VERSION_EXHAUSTED` (409) when stored and expected are `MAX_RECORD_VERSION`, and
640
+ * returns null otherwise. Without `options.expectedVersion` it behaves as before. A wrapper must
641
+ * forward every argument, `options` included.
642
+ */
643
+ update(model: Model, id: any, changes: any, session?: Session, options?: { expectedVersion?: number }): any;
644
+ /**
645
+ * With `options.expectedVersion`, one atomic delete that matches the ID and the version, and returns
646
+ * the deleted record. When nothing matches, it decides as `update` does, without the exhausted case:
647
+ * a delete does not advance the version, so a record at `MAX_RECORD_VERSION` is deleted like any
648
+ * other. Without it, it behaves as before. A wrapper must forward every argument, `options`
649
+ * included.
650
+ */
651
+ delete(model: Model, id: any, session?: Session, options?: { expectedVersion?: number }): any;
652
+ /**
653
+ * An adapter that declares `writeFeatures.optimisticConcurrency` must run `find` inside the given
654
+ * `session`, honor `options.requiredId` and accept `options.context`: the runtime reads through
655
+ * `find` with the caller's session to check, before a hook runs, whether the caller can read a
656
+ * record whose version is stale.
657
+ */
579
658
  find(model: Model, type: GraphQLObjectType, args: any, session?: Session | null, options?: { requiredId?: any; context?: any }): any;
580
659
  count(model: Model, type: GraphQLObjectType, args: any, session?: Session | null): Promise<number> | number;
581
660
  aggregate(model: Model, type: GraphQLObjectType, args: any, session?: Session | null): any;