@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 +8 -0
- package/package.json +2 -1
- package/src/index.js +1 -0
- package/src/metadata.js +24 -0
- package/src/record-version.js +235 -0
- package/src/runtime.js +423 -46
- package/types/index.d.ts +84 -5
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.
|
|
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
|
-
|
|
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
|
|
560
|
-
//
|
|
561
|
-
const
|
|
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]
|
|
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
|
|
818
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
1653
|
-
|
|
1654
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
578
|
-
|
|
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;
|