@simtlix/simfinity-core 3.12.0 → 3.14.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 +15 -3
- package/package.json +2 -1
- package/src/const/QLQuantifier.js +21 -0
- package/src/index.js +2 -0
- package/src/metadata.js +24 -0
- package/src/query-emptiness.js +2 -2
- package/src/query-frames.js +510 -0
- package/src/query-limits.js +9 -2
- package/src/query-operators.js +23 -9
- package/src/query-plan.js +236 -155
- package/src/record-version.js +235 -0
- package/src/runtime.js +664 -168
- package/types/index.d.ts +181 -23
|
@@ -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
|
+
};
|