@simtlix/simfinity-core 3.11.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 +10 -1
- 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 +718 -113
- package/types/index.d.ts +153 -5
package/src/runtime.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
|
|
7
7
|
GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
|
|
8
8
|
GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
|
|
9
|
-
Kind, getNamedType, isObjectType, isOutputType,
|
|
9
|
+
Kind, getNamedType, isEnumType, isListType, isNonNullType, isObjectType, isOutputType, isScalarType,
|
|
10
10
|
} from 'graphql';
|
|
11
11
|
|
|
12
12
|
import SimfinityError from './errors/simfinity.error.js';
|
|
@@ -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,7 +566,11 @@ 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 = {};
|
|
573
|
+
const registeredViews = {};
|
|
498
574
|
const middlewares = [];
|
|
499
575
|
|
|
500
576
|
const operations = {
|
|
@@ -555,9 +631,9 @@ export const createRuntime = (adapter) => {
|
|
|
555
631
|
}
|
|
556
632
|
};
|
|
557
633
|
|
|
558
|
-
// Object and interface types
|
|
559
|
-
//
|
|
560
|
-
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) => {
|
|
561
637
|
const visited = new Set();
|
|
562
638
|
const outputTypes = [];
|
|
563
639
|
const visit = (type) => {
|
|
@@ -573,14 +649,20 @@ export const createRuntime = (adapter) => {
|
|
|
573
649
|
named.getInterfaces().forEach(visit);
|
|
574
650
|
for (const field of Object.values(named.getFields())) visit(field.type);
|
|
575
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 = () => {
|
|
576
659
|
const registeredTypes = Object.values(typesDict.types).map(({ gqltype }) => gqltype);
|
|
577
660
|
const customOperations = [
|
|
578
661
|
...Object.values(registeredMutations),
|
|
579
662
|
...Object.values(registeredQueries),
|
|
580
663
|
];
|
|
581
664
|
const customOutputTypes = customOperations.map(({ outputModel }) => outputModel);
|
|
582
|
-
[...registeredTypes, ...customOutputTypes]
|
|
583
|
-
return outputTypes;
|
|
665
|
+
return collectOutputTypes([...registeredTypes, ...customOutputTypes]);
|
|
584
666
|
};
|
|
585
667
|
|
|
586
668
|
const hasUnresolvedRelation = (gqltype) => Object.values(gqltype.getFields()).some(
|
|
@@ -813,8 +895,10 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
|
|
|
813
895
|
+ `\`${operation}\` operation in generated inputs, ${omittedOperation[operation]()}`);
|
|
814
896
|
}
|
|
815
897
|
}
|
|
816
|
-
// Null
|
|
817
|
-
|
|
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) };
|
|
818
902
|
return fields;
|
|
819
903
|
},
|
|
820
904
|
});
|
|
@@ -1049,6 +1133,14 @@ const buildInputType = (gqltype, reachable) => {
|
|
|
1049
1133
|
description: fieldEntry.description,
|
|
1050
1134
|
};
|
|
1051
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
|
+
}
|
|
1052
1144
|
|
|
1053
1145
|
const inputTypeForAdd = new GraphQLInputObjectType({
|
|
1054
1146
|
name: `${gqltype.name}Input`,
|
|
@@ -1221,8 +1313,9 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
|
|
|
1221
1313
|
const collectionFields = {};
|
|
1222
1314
|
|
|
1223
1315
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
1224
|
-
// A virtual field stores nothing, whatever middleware or saveObject() passes for it
|
|
1225
|
-
|
|
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;
|
|
1226
1319
|
const fieldValue = ownValue(args, fieldEntryName);
|
|
1227
1320
|
if (fieldEntry.extensions && fieldEntry.extensions.validations
|
|
1228
1321
|
&& fieldEntry.extensions.validations[operation]) {
|
|
@@ -1338,10 +1431,58 @@ const iterateOnCollectionFields = async (materializedModel, gqltype, objectId, s
|
|
|
1338
1431
|
const readByIdIsInert = (Model) => typeof adapter.readByIdIsInert === 'function'
|
|
1339
1432
|
&& adapter.readByIdIsInert(Model) === true;
|
|
1340
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
|
+
|
|
1341
1479
|
// The record is read for the onDelete hook; adapter.delete casts the ID, deletes and returns the
|
|
1342
1480
|
// record itself. Without a hook, the read is skipped when it is inert. A nested delete passes the
|
|
1343
|
-
// child it has already read as `current
|
|
1344
|
-
|
|
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) => {
|
|
1345
1486
|
const hasHook = !!(controller && controller.onDelete);
|
|
1346
1487
|
let currentObject = current;
|
|
1347
1488
|
if (currentObject === undefined && (hasHook || !readByIdIsInert(Model))) {
|
|
@@ -1352,7 +1493,13 @@ const onDelete = async (Model, controller, id, session, context, current) => {
|
|
|
1352
1493
|
await controller.onDelete(currentObject, session, context);
|
|
1353
1494
|
}
|
|
1354
1495
|
|
|
1355
|
-
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;
|
|
1356
1503
|
};
|
|
1357
1504
|
|
|
1358
1505
|
const getEmbeddedFieldNames = (gqltype) => {
|
|
@@ -1427,7 +1574,7 @@ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
|
|
|
1427
1574
|
// onUpdating, so that a malformed ID fails before the hook, and before nested collection items, so
|
|
1428
1575
|
// that the SQL adapter locks the parent row before child writes; otherwise it skips the read only
|
|
1429
1576
|
// when it is inert. A state action or a nested update has already read and checked the record
|
|
1430
|
-
// (`recordChecked`).
|
|
1577
|
+
// (`recordChecked`). A protected type never reads here: its pre-read record serves the merge (#98).
|
|
1431
1578
|
const readsStoredEmbedded = (Model, controller, materializedModel, embeddedFieldNames, recordChecked) => {
|
|
1432
1579
|
const usesStored = embeddedFieldNames.some((name) => {
|
|
1433
1580
|
const value = ownValue(materializedModel.modelArgs, name);
|
|
@@ -1441,17 +1588,29 @@ const readsStoredEmbedded = (Model, controller, materializedModel, embeddedField
|
|
|
1441
1588
|
|| !readByIdIsInert(Model);
|
|
1442
1589
|
};
|
|
1443
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.
|
|
1444
1602
|
const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context,
|
|
1445
|
-
recordChecked = false) => {
|
|
1603
|
+
recordChecked = false, { expectedVersion, observedVersion, record } = {}) => {
|
|
1604
|
+
const versioned = isProtected(gqltype);
|
|
1446
1605
|
const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
|
|
1447
1606
|
const objectId = args.id;
|
|
1448
1607
|
const argTypes = gqltype.getFields();
|
|
1449
1608
|
const embeddedFieldNames = getEmbeddedFieldNames(gqltype);
|
|
1450
1609
|
|
|
1451
1610
|
if (embeddedFieldNames.length > 0
|
|
1452
|
-
&& readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked)) {
|
|
1611
|
+
&& (versioned || readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked))) {
|
|
1453
1612
|
const projection = Object.fromEntries(embeddedFieldNames.map((name) => [name, 1]));
|
|
1454
|
-
const currentObject = await adapter.getById(Model, objectId, session, {
|
|
1613
|
+
const currentObject = versioned ? record : await adapter.getById(Model, objectId, session, {
|
|
1455
1614
|
projection,
|
|
1456
1615
|
plain: true,
|
|
1457
1616
|
lock: true,
|
|
@@ -1476,7 +1635,8 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
1476
1635
|
}
|
|
1477
1636
|
|
|
1478
1637
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
1479
|
-
if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)
|
|
1638
|
+
if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)
|
|
1639
|
+
&& !isVersionField(fieldEntry)) {
|
|
1480
1640
|
materializedModel.modelArgs.$unset = {
|
|
1481
1641
|
...materializedModel.modelArgs.$unset,
|
|
1482
1642
|
[getFieldStorageName(fieldEntryName, fieldEntry)]: '',
|
|
@@ -1491,11 +1651,24 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
1491
1651
|
|
|
1492
1652
|
if (controller && controller.onUpdating) {
|
|
1493
1653
|
await controller.onUpdating(objectId, update, session, context);
|
|
1654
|
+
if (versioned && setsVersion(update)) throw versionManagedError(gqltype.name, 'onUpdating');
|
|
1494
1655
|
}
|
|
1495
1656
|
|
|
1496
1657
|
if (linkToParent) linkToParent(update);
|
|
1497
|
-
const result =
|
|
1658
|
+
const result = versioned
|
|
1659
|
+
? await adapter.update(Model, objectId, update, session, { expectedVersion })
|
|
1660
|
+
: await adapter.update(Model, objectId, update, session);
|
|
1498
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
|
+
}
|
|
1499
1672
|
|
|
1500
1673
|
if (materializedModel.collectionFields) {
|
|
1501
1674
|
// Children link to, and are owned through, the parent's stored key, as when a new parent is
|
|
@@ -1512,18 +1685,27 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
1512
1685
|
return result;
|
|
1513
1686
|
};
|
|
1514
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).
|
|
1515
1690
|
const onStateChanged = async (Model, gqltype, controller, args, session, actionField, context) => {
|
|
1516
|
-
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;
|
|
1517
1697
|
if (!storedModel) {
|
|
1518
1698
|
throw new SimfinityError(`${gqltype.name} ${args.id} is not valid`, 'NOT_VALID_ID', 404);
|
|
1519
1699
|
}
|
|
1700
|
+
if (versioned) await assertRecordVersion(gqltype, args.id, storedModel, expectedVersion, true, context, session);
|
|
1520
1701
|
if (storedModel.state === stateValue(actionField.from)) {
|
|
1521
1702
|
if (actionField.action) {
|
|
1522
1703
|
await actionField.action(args, session);
|
|
1523
1704
|
}
|
|
1524
1705
|
|
|
1525
1706
|
args.state = stateValue(actionField.to);
|
|
1526
|
-
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);
|
|
1527
1709
|
result = adapter.toObject(result);
|
|
1528
1710
|
result.state = actionField.to.value;
|
|
1529
1711
|
return result;
|
|
@@ -1538,11 +1720,15 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
|
|
|
1538
1720
|
typesDict.types[gqltype.name].stateMachine.initialState,
|
|
1539
1721
|
);
|
|
1540
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;
|
|
1541
1726
|
|
|
1542
1727
|
const newObject = adapter.newRecord(Model, materializedModel.modelArgs, session);
|
|
1543
1728
|
|
|
1544
1729
|
if (controller && controller.onSaving) {
|
|
1545
1730
|
await controller.onSaving(newObject, args, session, context);
|
|
1731
|
+
if (versioned && newObject.version !== 1) throw versionManagedError(gqltype.name, 'onSaving');
|
|
1546
1732
|
}
|
|
1547
1733
|
|
|
1548
1734
|
if (linkToParent) linkToParent(newObject);
|
|
@@ -1634,12 +1820,30 @@ const assertCollectionWrites = (gqltype, input) => {
|
|
|
1634
1820
|
}
|
|
1635
1821
|
};
|
|
1636
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.
|
|
1637
1838
|
const executeOperation = (Model, gqltype, controller, args, operation, actionField,
|
|
1638
1839
|
session, context, inputType) => {
|
|
1639
1840
|
const limit = getMaxNestedOperations();
|
|
1640
1841
|
if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
|
|
1641
1842
|
if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
|
|
1642
|
-
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 });
|
|
1643
1847
|
return adapter.withTransaction(
|
|
1644
1848
|
session,
|
|
1645
1849
|
async (mySession) => {
|
|
@@ -1647,10 +1851,17 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
|
|
|
1647
1851
|
switch (operation) {
|
|
1648
1852
|
case operations.SAVE:
|
|
1649
1853
|
return onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
|
|
1650
|
-
case operations.UPDATE:
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
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
|
+
}
|
|
1654
1865
|
case operations.STATE_CHANGED:
|
|
1655
1866
|
return onStateChanged(Model, gqltype, controller,
|
|
1656
1867
|
attemptArgs, mySession, actionField, context);
|
|
@@ -1683,14 +1894,28 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
|
|
|
1683
1894
|
if (item.$set) delete item.$set[connection.storageFieldName];
|
|
1684
1895
|
};
|
|
1685
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;
|
|
1686
1900
|
for (const element of collectionFieldsList) {
|
|
1687
1901
|
if (element === null) continue;
|
|
1688
|
-
|
|
1902
|
+
let args = { input: element };
|
|
1903
|
+
if (operationType === operations.DELETE) {
|
|
1904
|
+
args = versioned ? { id: element?.id, version: element?.version } : { id: element };
|
|
1905
|
+
}
|
|
1689
1906
|
await executeMiddleware({ type, args, operation: operationType, context });
|
|
1690
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
|
+
|
|
1691
1914
|
// The ownership check reads the child (the SQL adapter locks its row). onDelete receives this
|
|
1692
|
-
// 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.
|
|
1693
1917
|
let child;
|
|
1918
|
+
let observedVersion;
|
|
1694
1919
|
if (operationType === operations.UPDATE || operationType === operations.DELETE) {
|
|
1695
1920
|
const id = operationType === operations.DELETE ? args.id : args.input.id;
|
|
1696
1921
|
child = await adapter.getById(type.model, id, session, { plain: true, lock: true });
|
|
@@ -1700,6 +1925,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
|
|
|
1700
1925
|
if (String(child[connection.storageFieldName]) !== String(parentKey)) {
|
|
1701
1926
|
throw new ForbiddenError('Child does not belong to this parent');
|
|
1702
1927
|
}
|
|
1928
|
+
if (versioned) {
|
|
1929
|
+
observedVersion = normalizeProtectedVersion(child, collectionGQLType, id);
|
|
1930
|
+
await assertRecordVersion(collectionGQLType, id, child, expectedVersion, operationType === operations.UPDATE,
|
|
1931
|
+
context, session);
|
|
1932
|
+
}
|
|
1703
1933
|
}
|
|
1704
1934
|
|
|
1705
1935
|
switch (operationType) {
|
|
@@ -1709,10 +1939,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
|
|
|
1709
1939
|
break;
|
|
1710
1940
|
case operations.UPDATE:
|
|
1711
1941
|
await onUpdateSubject(type.model, collectionGQLType, type.controller,
|
|
1712
|
-
args.input, session, linkToParent, context, true);
|
|
1942
|
+
args.input, session, linkToParent, context, true, versioned ? { expectedVersion, observedVersion, record: child } : undefined);
|
|
1713
1943
|
break;
|
|
1714
1944
|
case operations.DELETE:
|
|
1715
|
-
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);
|
|
1716
1947
|
}
|
|
1717
1948
|
}
|
|
1718
1949
|
};
|
|
@@ -1813,7 +2044,9 @@ const copyClientFilters = (args, operation) => {
|
|
|
1813
2044
|
// scope. When the adapter compiles reference emptiness (#108 part 2b), a client's emptiness term or
|
|
1814
2045
|
// condition reads its references and collections through scoped joins and frames instead, so it
|
|
1815
2046
|
// enters no joined scope either; every other entry, beside it or on the same path, records its own.
|
|
1816
|
-
|
|
2047
|
+
// Without `checkQueryable`, for a view's `where`, whose paths are the author's as a scope's are, a
|
|
2048
|
+
// non-queryable field is not rejected; a virtual one still is.
|
|
2049
|
+
const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true } = {}) => {
|
|
1817
2050
|
copyClientFilters(args, operation);
|
|
1818
2051
|
const joinedScopes = new Map();
|
|
1819
2052
|
const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
|
|
@@ -1827,7 +2060,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
|
|
|
1827
2060
|
throw forbiddenPath(`Query path ${path} uses the virtual field ${type.name}.${fieldName}, which is not stored`);
|
|
1828
2061
|
}
|
|
1829
2062
|
const queryable = field.extensions?.queryable;
|
|
1830
|
-
if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
|
|
2063
|
+
if (checkQueryable && (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName)))) {
|
|
1831
2064
|
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
1832
2065
|
}
|
|
1833
2066
|
if (!joins) return;
|
|
@@ -2069,31 +2302,34 @@ const hasScopeMarker = (construct) => construct != null && Object.hasOwn(constru
|
|
|
2069
2302
|
|
|
2070
2303
|
// Marks the client's constructs that read references or collections, from the gate's conditions on the
|
|
2071
2304
|
// client's arguments, then applies maxReferenceFrames to the distinct (kind, path) pairs of their
|
|
2072
|
-
// scoped joins and frames in
|
|
2073
|
-
// id -> the client's operator and path, or null when the adapter does not compile
|
|
2074
|
-
// emptiness. Ids are ordinals in the order of the conditions, so identical arguments get
|
|
2075
|
-
// markers and sibling collection reads keep one batch key.
|
|
2076
|
-
|
|
2305
|
+
// scoped joins and frames in these conditions, so every 2a code comes first. Returns the issued
|
|
2306
|
+
// markers, as id -> the client's operator and path, or null when the adapter does not compile
|
|
2307
|
+
// reference emptiness. Ids are ordinals in the order of the conditions, so identical arguments get
|
|
2308
|
+
// identical markers and sibling collection reads keep one batch key. With `issued`, the markers of
|
|
2309
|
+
// the call so far, the ids continue on that map, so that a view's `where`, marked after the client's
|
|
2310
|
+
// arguments, shares them; the limit still applies to each set of conditions on its own.
|
|
2311
|
+
const markClientConstructs = (checked, issued) => {
|
|
2077
2312
|
if (!supportsReferenceEmptiness) return null;
|
|
2078
|
-
const
|
|
2313
|
+
const markers = issued ?? new Map();
|
|
2079
2314
|
const pairs = new Set();
|
|
2080
2315
|
for (const { entry, shape } of checked) {
|
|
2081
2316
|
const { construct } = entry;
|
|
2082
2317
|
if (!shape || !construct || hasScopeMarker(construct)) continue;
|
|
2083
2318
|
const { joins, frames } = emptinessScopePaths(shape, entry.segments);
|
|
2084
2319
|
if (!joins.length && !frames.length) continue;
|
|
2085
|
-
const id =
|
|
2320
|
+
const id = markers.size;
|
|
2086
2321
|
construct[SCOPE_MARKER] = { id };
|
|
2087
|
-
|
|
2322
|
+
markers.set(id, { operator: entry.operator, path: entry.path });
|
|
2088
2323
|
for (const path of joins) pairs.add(`join ${path}`);
|
|
2089
2324
|
for (const path of frames) pairs.add(`frame ${path}`);
|
|
2090
2325
|
}
|
|
2091
2326
|
const limit = getQueryMaxReferenceFrames();
|
|
2092
2327
|
if (pairs.size > limit) {
|
|
2093
2328
|
throw new SimfinityError(`Emptiness conditions through references and collections need ${pairs.size} scoped joins and `
|
|
2094
|
-
+
|
|
2329
|
+
+ 'frames in one set of conditions (the client\'s arguments of a query field, or a view\'s where), more than the limit '
|
|
2330
|
+
+ `of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
|
|
2095
2331
|
}
|
|
2096
|
-
return
|
|
2332
|
+
return markers;
|
|
2097
2333
|
};
|
|
2098
2334
|
|
|
2099
2335
|
// The detach check: every marker issued in this call sits, well formed (an object whose only own key
|
|
@@ -2298,6 +2534,29 @@ const loadReference = (type, id, requiredId, context) => {
|
|
|
2298
2534
|
});
|
|
2299
2535
|
};
|
|
2300
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
|
+
|
|
2301
2560
|
// `requiredId` marks a generated single-reference read. Middleware still runs per call; then, when the
|
|
2302
2561
|
// adapter reads by ID in batches, and unless the type has a get_by_id scope or middleware changed the
|
|
2303
2562
|
// ID, the read joins the request's batch.
|
|
@@ -2311,12 +2570,7 @@ const resolveById = async (type, args, context, requiredId) => {
|
|
|
2311
2570
|
}
|
|
2312
2571
|
return adapter.getById(type.model, args.id, null, { requiredId, context });
|
|
2313
2572
|
}
|
|
2314
|
-
|
|
2315
|
-
await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
|
|
2316
|
-
assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
|
|
2317
|
-
assertQueryConditions(type.gqltype, queryArgs, 'find');
|
|
2318
|
-
const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
|
|
2319
|
-
return results[0] || null;
|
|
2573
|
+
return findVisibleById(type, { id: { operator: 'EQ', value: args.id } }, null, context, requiredId);
|
|
2320
2574
|
};
|
|
2321
2575
|
|
|
2322
2576
|
// Batched collection reads. A generated collection field reads its children with one
|
|
@@ -2707,10 +2961,13 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
2707
2961
|
: `mutation that takes no input or an input of its own, since getInputType(${type.gqltype.name}) `
|
|
2708
2962
|
+ 'has no fields.'));
|
|
2709
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) };
|
|
2710
2967
|
rootQueryArgs.fields[`delete${type.simpleEntityEndpointName}`] = {
|
|
2711
2968
|
type: type.gqltype,
|
|
2712
2969
|
description: 'delete',
|
|
2713
|
-
args:
|
|
2970
|
+
args: deleteArgs,
|
|
2714
2971
|
extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.DELETE } },
|
|
2715
2972
|
async resolve(parent, args, context) {
|
|
2716
2973
|
const params = {
|
|
@@ -2721,8 +2978,10 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
2721
2978
|
};
|
|
2722
2979
|
|
|
2723
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;
|
|
2724
2983
|
return executeOperation(type.model, type.gqltype, type.controller,
|
|
2725
|
-
|
|
2984
|
+
target, operations.DELETE, null, null, context, null);
|
|
2726
2985
|
},
|
|
2727
2986
|
};
|
|
2728
2987
|
}
|
|
@@ -2805,6 +3064,170 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
2805
3064
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2806
3065
|
};
|
|
2807
3066
|
|
|
3067
|
+
// The prefix every generated read shares, for the list and aggregate roots, the collection fields and
|
|
3068
|
+
// the views: the client's paths and conditions are inspected and marked, middleware runs, with `entry`
|
|
3069
|
+
// when one is given, `extend` adds the caller's own constructs and returns their joined scopes, the
|
|
3070
|
+
// type's scope runs, the client's joined scopes and then the extension's are applied, and the final
|
|
3071
|
+
// arguments are settled. Returns the params, whose args the adapter reads. An empty map of the
|
|
3072
|
+
// client's joined scopes is not awaited, so that, with the caller's await of this function, a read
|
|
3073
|
+
// without one still reaches the adapter after as many promise jobs as before it was shared.
|
|
3074
|
+
const prepareRead = async (type, args, context, operation, { entry, extend } = {}) => {
|
|
3075
|
+
const joinedScopes = inspectClientPaths(type.gqltype, args, operation);
|
|
3076
|
+
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation));
|
|
3077
|
+
const params = entry === undefined
|
|
3078
|
+
? { type, args, operation, context }
|
|
3079
|
+
: { type, args, operation, entry, context };
|
|
3080
|
+
await executeMiddleware(params);
|
|
3081
|
+
const extended = extend ? await extend(params, issued) : null;
|
|
3082
|
+
await executeScope(params);
|
|
3083
|
+
if (joinedScopes.size) await applyJoinedScopes(joinedScopes, params);
|
|
3084
|
+
if (extended) await applyJoinedScopes(extended, params);
|
|
3085
|
+
const filling = settleClientArgs(type.gqltype, params, operation, issued);
|
|
3086
|
+
if (filling) await filling;
|
|
3087
|
+
return params;
|
|
3088
|
+
};
|
|
3089
|
+
|
|
3090
|
+
// The tail of a list read, shared by the generated list root and the views: the rows and, when the
|
|
3091
|
+
// client asks for pagination.count and something can receive it, the count, reported through the sink.
|
|
3092
|
+
const readList = async (type, params, parent, info, context) => {
|
|
3093
|
+
const queryArgs = params.args;
|
|
3094
|
+
const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
|
|
3095
|
+
&& canReportCount(parent, info, context);
|
|
3096
|
+
|
|
3097
|
+
const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
|
|
3098
|
+
const countPromise = wantsCount
|
|
3099
|
+
? adapter.count(type.model, type.gqltype, queryArgs, null)
|
|
3100
|
+
: null;
|
|
3101
|
+
|
|
3102
|
+
const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
|
|
3103
|
+
if (wantsCount && !reportCount(parent, info, context, resultCount)) {
|
|
3104
|
+
warnOnce(runtimeIdentity, 'count-context', 'Configuration issue: the GraphQL context does not accept '
|
|
3105
|
+
+ 'a count property (it is frozen, sealed or has a read-only count), so pagination counts are not '
|
|
3106
|
+
+ 'reported; pass a fresh mutable context object for each request.');
|
|
3107
|
+
}
|
|
3108
|
+
return result;
|
|
3109
|
+
};
|
|
3110
|
+
|
|
3111
|
+
// Separates a view's params, named by `paramNames`, from the rest of its arguments, which are exactly
|
|
3112
|
+
// a generated list's.
|
|
3113
|
+
const splitViewArgs = (paramNames, args) => {
|
|
3114
|
+
const params = {};
|
|
3115
|
+
const rest = {};
|
|
3116
|
+
for (const [name, value] of Object.entries(args)) {
|
|
3117
|
+
if (paramNames.has(name)) params[name] = value;
|
|
3118
|
+
else rest[name] = value;
|
|
3119
|
+
}
|
|
3120
|
+
return { params, args: rest };
|
|
3121
|
+
};
|
|
3122
|
+
|
|
3123
|
+
const invalidWhere = (view, detail) => new SimfinityError(`View ${view.name} where ${detail}`, 'INVALID_VIEW', 500);
|
|
3124
|
+
|
|
3125
|
+
// The groups a view's `where` returned are plain objects whose `conditions`, `AND` and `OR`, when set,
|
|
3126
|
+
// are arrays: of plain objects with a field name, and of groups like these. collectQueryPathEntries
|
|
3127
|
+
// skips anything else silently, so this comes before their paths are read. Only the GraphQL input
|
|
3128
|
+
// fields are structural keys; condition values stay opaque. A condition never carries a client scope
|
|
3129
|
+
// marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
|
|
3130
|
+
const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
|
|
3131
|
+
const groupFields = QLFilterGroup.getFields();
|
|
3132
|
+
const conditionFields = QLFilterCondition.getFields();
|
|
3133
|
+
for (const group of groups) {
|
|
3134
|
+
if (!isNonNullPlainObject(group)) throw invalidWhere(view, 'returned a group that is not a plain object');
|
|
3135
|
+
if (ancestors.has(group)) throw invalidWhere(view, 'returned a cyclic group');
|
|
3136
|
+
for (const key of Object.keys(group)) {
|
|
3137
|
+
if (!Object.hasOwn(groupFields, key)) throw invalidWhere(view, `returned a group with unknown key ${key}`);
|
|
3138
|
+
}
|
|
3139
|
+
for (const key of Object.keys(groupFields)) {
|
|
3140
|
+
if (group[key] != null && !Array.isArray(group[key])) throw invalidWhere(view, `returned a group whose ${key} is not an array`);
|
|
3141
|
+
}
|
|
3142
|
+
for (const condition of group.conditions ?? []) {
|
|
3143
|
+
if (!isNonNullPlainObject(condition) || typeof condition.field !== 'string' || condition.field === '') {
|
|
3144
|
+
throw invalidWhere(view, 'returned a condition that is not a plain object with a field name');
|
|
3145
|
+
}
|
|
3146
|
+
if (hasScopeMarker(condition)) {
|
|
3147
|
+
throw invalidWhere(view, `condition on ${condition.field} carries a ${SCOPE_MARKER} key, which Simfinity reserves`);
|
|
3148
|
+
}
|
|
3149
|
+
for (const key of Object.keys(condition)) {
|
|
3150
|
+
if (!Object.hasOwn(conditionFields, key)) throw invalidWhere(view, `condition on ${condition.field} has unknown key ${key}`);
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
ancestors.add(group);
|
|
3154
|
+
assertViewGroupShapes(view, [...(group.AND ?? []), ...(group.OR ?? [])], ancestors);
|
|
3155
|
+
ancestors.delete(group);
|
|
3156
|
+
}
|
|
3157
|
+
};
|
|
3158
|
+
|
|
3159
|
+
// A `where` that does not return an array of groups, or names a field its type does not have, a path
|
|
3160
|
+
// past a field without fields, or a virtual field, is the author's bug: INVALID_VIEW (500), never the
|
|
3161
|
+
// FORBIDDEN_FILTER_PATH (403) that inspectClientPaths gives a client for a virtual field. Each path is
|
|
3162
|
+
// walked here, since walkQueryPath stops silently at an unknown segment.
|
|
3163
|
+
const assertViewGroups = (view, groups) => {
|
|
3164
|
+
if (!Array.isArray(groups)) throw invalidWhere(view, 'must return an array of AND groups');
|
|
3165
|
+
assertViewGroupShapes(view, groups);
|
|
3166
|
+
for (const { segments } of collectQueryPathEntries({ AND: groups }, 'find', { gqltype: view.type })) {
|
|
3167
|
+
let type = view.type;
|
|
3168
|
+
let parent = null;
|
|
3169
|
+
for (const [index, segment] of segments.entries()) {
|
|
3170
|
+
const names = (detail) => invalidWhere(view, `names ${segments.slice(0, index + 1).join('.')}, which is ${detail}`);
|
|
3171
|
+
if (typeof type.getFields !== 'function') throw names(`past ${parent}, whose type ${type.name} has no fields`);
|
|
3172
|
+
const fields = type.getFields();
|
|
3173
|
+
if (!Object.hasOwn(fields, segment)) throw names(`not a field of ${type.name}`);
|
|
3174
|
+
if (isVirtualField(fields[segment])) throw names(`the virtual field ${type.name}.${segment}`);
|
|
3175
|
+
parent = `${type.name}.${segment}`;
|
|
3176
|
+
type = unwrapListAndNonNull(fields[segment].type);
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
};
|
|
3180
|
+
|
|
3181
|
+
// The `extend` of a view's read (see prepareRead). After middleware, the groups of `where` are read as
|
|
3182
|
+
// the client's constructs on a probe, whose AND holds copies of them (inspectClientPaths), so the
|
|
3183
|
+
// author's objects are never touched: their paths are inspected, their conditions checked and marked,
|
|
3184
|
+
// with ids that continue the client's, then the copies are appended to the top-level AND, where no
|
|
3185
|
+
// middleware can drop them. Returns their joined scopes, which attach to the appended copies. Their
|
|
3186
|
+
// paths are the author's, as a scope's are, so a non-queryable field is not rejected.
|
|
3187
|
+
const viewExtension = (view, viewParams) => async (params, issued) => {
|
|
3188
|
+
const groups = await view.where({ params: viewParams, context: params.context });
|
|
3189
|
+
assertViewGroups(view, groups);
|
|
3190
|
+
const probe = { AND: groups };
|
|
3191
|
+
const scopes = inspectClientPaths(view.type, probe, 'find', { checkQueryable: false });
|
|
3192
|
+
const checked = assertQueryConditions(view.type, probe, 'find');
|
|
3193
|
+
markClientConstructs(checked, issued);
|
|
3194
|
+
appendAndGroups(params.args, probe.AND);
|
|
3195
|
+
return scopes;
|
|
3196
|
+
};
|
|
3197
|
+
|
|
3198
|
+
// A view's root field: the generated list of its type, with the view's params before the generated
|
|
3199
|
+
// arguments. Each call reads the type's registration, whose model createSchema prepares, strips the
|
|
3200
|
+
// params, by the names the field was built with, before the list pipeline runs, and has the pipeline
|
|
3201
|
+
// append the view's `where` after middleware (viewExtension).
|
|
3202
|
+
const buildViewField = (view) => {
|
|
3203
|
+
const paramArgs = {};
|
|
3204
|
+
for (const [paramName, param] of Object.entries(view.params)) {
|
|
3205
|
+
const arg = { type: param.type };
|
|
3206
|
+
if (Object.hasOwn(param, 'description')) arg.description = param.description;
|
|
3207
|
+
if (Object.hasOwn(param, 'defaultValue')) arg.defaultValue = param.defaultValue;
|
|
3208
|
+
paramArgs[paramName] = arg;
|
|
3209
|
+
}
|
|
3210
|
+
const paramNames = new Set(Object.keys(paramArgs));
|
|
3211
|
+
return {
|
|
3212
|
+
type: new GraphQLList(view.type),
|
|
3213
|
+
description: view.description,
|
|
3214
|
+
args: { ...paramArgs, ...createArgsForQuery(view.type) },
|
|
3215
|
+
extensions: {
|
|
3216
|
+
simfinityQuery: {
|
|
3217
|
+
typeName: view.type.name, operation: 'find', countSink: true, view: { name: view.name },
|
|
3218
|
+
},
|
|
3219
|
+
},
|
|
3220
|
+
async resolve(parent, args, context, info) {
|
|
3221
|
+
const registration = typesDict.types[view.type.name];
|
|
3222
|
+
const split = splitViewArgs(paramNames, args);
|
|
3223
|
+
const params = await prepareRead(registration, split.args, context, 'find', {
|
|
3224
|
+
entry: view.name, extend: viewExtension(view, split.params),
|
|
3225
|
+
});
|
|
3226
|
+
return readList(registration, params, parent, info, context);
|
|
3227
|
+
},
|
|
3228
|
+
};
|
|
3229
|
+
};
|
|
3230
|
+
|
|
2808
3231
|
const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult, includedCustomQueries = null) => {
|
|
2809
3232
|
const rootQueryArgs = {};
|
|
2810
3233
|
rootQueryArgs.name = name;
|
|
@@ -2831,35 +3254,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2831
3254
|
// that supplies a root-value sink can pass its context unchanged.
|
|
2832
3255
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
|
|
2833
3256
|
async resolve(parent, args, context, info) {
|
|
2834
|
-
const
|
|
2835
|
-
|
|
2836
|
-
const params = {
|
|
2837
|
-
type,
|
|
2838
|
-
args,
|
|
2839
|
-
operation: 'find',
|
|
2840
|
-
context,
|
|
2841
|
-
};
|
|
2842
|
-
await executeMiddleware(params);
|
|
2843
|
-
await executeScope(params);
|
|
2844
|
-
await applyJoinedScopes(joinedScopes, params);
|
|
2845
|
-
const filling = settleClientArgs(type.gqltype, params, 'find', issued);
|
|
2846
|
-
if (filling) await filling;
|
|
2847
|
-
const queryArgs = params.args;
|
|
2848
|
-
const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
|
|
2849
|
-
&& canReportCount(parent, info, context);
|
|
2850
|
-
|
|
2851
|
-
const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
|
|
2852
|
-
const countPromise = wantsCount
|
|
2853
|
-
? adapter.count(type.model, type.gqltype, queryArgs, null)
|
|
2854
|
-
: null;
|
|
2855
|
-
|
|
2856
|
-
const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
|
|
2857
|
-
if (wantsCount && !reportCount(parent, info, context, resultCount)) {
|
|
2858
|
-
warnOnce(runtimeIdentity, 'count-context', 'Configuration issue: the GraphQL context does not accept '
|
|
2859
|
-
+ 'a count property (it is frozen, sealed or has a read-only count), so pagination counts are not '
|
|
2860
|
-
+ 'reported; pass a fresh mutable context object for each request.');
|
|
2861
|
-
}
|
|
2862
|
-
return result;
|
|
3257
|
+
const params = await prepareRead(type, args, context, 'find');
|
|
3258
|
+
return readList(type, params, parent, info, context);
|
|
2863
3259
|
},
|
|
2864
3260
|
};
|
|
2865
3261
|
|
|
@@ -2879,19 +3275,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2879
3275
|
args: aggregateArgsObject,
|
|
2880
3276
|
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
|
|
2881
3277
|
async resolve(parent, args, context) {
|
|
2882
|
-
const
|
|
2883
|
-
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'aggregate'));
|
|
2884
|
-
const params = {
|
|
2885
|
-
type,
|
|
2886
|
-
args,
|
|
2887
|
-
operation: 'aggregate',
|
|
2888
|
-
context,
|
|
2889
|
-
};
|
|
2890
|
-
await executeMiddleware(params);
|
|
2891
|
-
await executeScope(params);
|
|
2892
|
-
await applyJoinedScopes(joinedScopes, params);
|
|
2893
|
-
const filling = settleClientArgs(type.gqltype, params, 'aggregate', issued);
|
|
2894
|
-
if (filling) await filling;
|
|
3278
|
+
const params = await prepareRead(type, args, context, 'aggregate');
|
|
2895
3279
|
return adapter.aggregate(type.model, type.gqltype, params.args, null);
|
|
2896
3280
|
},
|
|
2897
3281
|
};
|
|
@@ -2906,6 +3290,13 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
|
|
|
2906
3290
|
}
|
|
2907
3291
|
}
|
|
2908
3292
|
|
|
3293
|
+
// A view is in the schema exactly when its type is; assertViewsAvailable has checked its name.
|
|
3294
|
+
for (const view of Object.values(registeredViews)) {
|
|
3295
|
+
if (!shouldNotBeIncludedInSchema(includedTypes, view.type)) {
|
|
3296
|
+
rootQueryArgs.fields[view.name] = buildViewField(view);
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
|
|
2909
3300
|
return new GraphQLObjectType(rootQueryArgs);
|
|
2910
3301
|
};
|
|
2911
3302
|
|
|
@@ -3179,6 +3570,108 @@ const warnUndeclaredVirtualFields = () => {
|
|
|
3179
3570
|
}
|
|
3180
3571
|
};
|
|
3181
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
|
+
|
|
3182
3675
|
const getRegistrations = () => Object.values(typesDict.types);
|
|
3183
3676
|
|
|
3184
3677
|
const includedOperationTypes = (registrations, includedOperations) => Object.entries(registrations)
|
|
@@ -3186,7 +3679,8 @@ const includedOperationTypes = (registrations, includedOperations) => Object.ent
|
|
|
3186
3679
|
.flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean));
|
|
3187
3680
|
|
|
3188
3681
|
// Application types that a schema with these allowlists contains, whatever else it reaches: the
|
|
3189
|
-
// 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.
|
|
3190
3684
|
const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations, includedCustomQueries) => [
|
|
3191
3685
|
...Object.values(typesDict.types)
|
|
3192
3686
|
.filter((type) => type.endpoint
|
|
@@ -3195,6 +3689,9 @@ const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, inc
|
|
|
3195
3689
|
.map((type) => type.gqltype),
|
|
3196
3690
|
...includedOperationTypes(registeredMutations, includedCustomMutations),
|
|
3197
3691
|
...includedOperationTypes(registeredQueries, includedCustomQueries),
|
|
3692
|
+
...Object.values(registeredViews)
|
|
3693
|
+
.filter(({ type }) => !shouldNotBeIncludedInSchema(includedQueryTypes, type))
|
|
3694
|
+
.flatMap(({ params }) => Object.values(params).map(({ type }) => getNamedType(type))),
|
|
3198
3695
|
];
|
|
3199
3696
|
|
|
3200
3697
|
const queryNameConflict = (name) => new SimfinityError(
|
|
@@ -3202,14 +3699,53 @@ const queryNameConflict = (name) => new SimfinityError(
|
|
|
3202
3699
|
'QUERY_NAME_CONFLICT', 500,
|
|
3203
3700
|
);
|
|
3204
3701
|
|
|
3702
|
+
// The root names of the generated queries of the endpoint types a schema includes.
|
|
3703
|
+
const generatedQueryNames = (includedQueryTypes) => Object.values(typesDict.types)
|
|
3704
|
+
.filter((type) => type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
|
|
3705
|
+
.flatMap((type) => [type.simpleEntityEndpointName, type.listEntitiesEndpointName, `${type.listEntitiesEndpointName}_aggregate`]);
|
|
3706
|
+
|
|
3707
|
+
const isIncludedCustomQuery = (name, includedCustomQueries) => Object.hasOwn(registeredQueries, name)
|
|
3708
|
+
&& !shouldNotBeIncludedInSchema(includedCustomQueries, name);
|
|
3709
|
+
|
|
3205
3710
|
const assertQueryNamesAvailable = (includedQueryTypes, includedCustomQueries) => {
|
|
3206
|
-
for (const
|
|
3207
|
-
if (
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3711
|
+
for (const name of generatedQueryNames(includedQueryTypes)) {
|
|
3712
|
+
if (isIncludedCustomQuery(name, includedCustomQueries)) throw queryNameConflict(name);
|
|
3713
|
+
}
|
|
3714
|
+
};
|
|
3715
|
+
|
|
3716
|
+
// Every registered view, whether or not the schema includes it: its type is an endpoint type of this
|
|
3717
|
+
// runtime, and its params are valid argument names that are neither fields of the type, which covers
|
|
3718
|
+
// stored, reference, collection and virtual fields, nor the generated list arguments. A view that the
|
|
3719
|
+
// schema includes, because it includes the view's type, also needs a name that no included generated
|
|
3720
|
+
// query or custom query takes.
|
|
3721
|
+
const assertViewsAvailable = (includedQueryTypes, includedCustomQueries) => {
|
|
3722
|
+
const views = Object.values(registeredViews);
|
|
3723
|
+
if (!views.length) return;
|
|
3724
|
+
const generatedNames = new Set(generatedQueryNames(includedQueryTypes));
|
|
3725
|
+
for (const { name, type, params } of views) {
|
|
3726
|
+
const registration = typesDict.types[type.name];
|
|
3727
|
+
if (!registration || registration.gqltype !== type || registration.endpoint !== true
|
|
3728
|
+
|| !registration.simpleEntityEndpointName) {
|
|
3729
|
+
throw invalidView(`${name} needs a type registered with connect() in this runtime`);
|
|
3730
|
+
}
|
|
3731
|
+
const fields = type.getFields();
|
|
3732
|
+
for (const paramName of Object.keys(params)) {
|
|
3733
|
+
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(paramName) || paramName.startsWith('__')) {
|
|
3734
|
+
throw invalidView(`${name} param ${paramName} requires a valid GraphQL name that does not start with __`);
|
|
3735
|
+
}
|
|
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))) {
|
|
3739
|
+
throw invalidView(`${name} param ${paramName} has the name of a field of ${type.name}`);
|
|
3740
|
+
}
|
|
3741
|
+
if (LIST_QUERY_ARGS.includes(paramName)) {
|
|
3742
|
+
throw invalidView(`${name} param ${paramName} has the name of a generated list query argument`);
|
|
3211
3743
|
}
|
|
3212
3744
|
}
|
|
3745
|
+
if (!shouldNotBeIncludedInSchema(includedQueryTypes, type)
|
|
3746
|
+
&& (generatedNames.has(name) || isIncludedCustomQuery(name, includedCustomQueries))) {
|
|
3747
|
+
throw queryNameConflict(name);
|
|
3748
|
+
}
|
|
3213
3749
|
}
|
|
3214
3750
|
};
|
|
3215
3751
|
|
|
@@ -3241,6 +3777,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3241
3777
|
// Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
|
|
3242
3778
|
const { includedCustomQueries } = readSchemaOptions(options);
|
|
3243
3779
|
assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
|
|
3780
|
+
assertViewsAvailable(includedQueryTypes, includedCustomQueries);
|
|
3244
3781
|
Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
|
|
3245
3782
|
const schemaTypes = collectSchemaOutputTypes();
|
|
3246
3783
|
schemaTypes.forEach(assertNotBoundElsewhere);
|
|
@@ -3252,16 +3789,21 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3252
3789
|
// An application type with the name of a field metadata type that an earlier schema fixed; one that
|
|
3253
3790
|
// only generated types or the fields of an input object reach is found when the schema is
|
|
3254
3791
|
// constructed.
|
|
3255
|
-
|
|
3256
|
-
includedCustomMutations, includedCustomQueries)
|
|
3792
|
+
const applicationTypes = includedApplicationTypes(includedQueryTypes, includedMutationTypes,
|
|
3793
|
+
includedCustomMutations, includedCustomQueries);
|
|
3794
|
+
assertFieldMetadataNamesFree(applicationTypes);
|
|
3257
3795
|
const referencedTypes = markReferencedTypesForModelGeneration();
|
|
3258
3796
|
// Entities, which keep their own stored identity: connect() types and relation targets.
|
|
3259
3797
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
3260
3798
|
typeInfo.storedIdentity = typeInfo.endpoint === true || referencedTypes.has(typeInfo);
|
|
3261
3799
|
});
|
|
3800
|
+
const { protectedTypes, reserveVersionNames } = assertConcurrencyDeclarations(applicationTypes);
|
|
3262
3801
|
if (adapter.prepare) {
|
|
3263
3802
|
adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
|
|
3264
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));
|
|
3265
3807
|
|
|
3266
3808
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
3267
3809
|
if (typeInfo.gqltype && !typeInfo.model) {
|
|
@@ -3272,6 +3814,8 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3272
3814
|
} else if (typeInfo.needsModel) {
|
|
3273
3815
|
typeInfo.model = adapter.createModel(typeInfo.gqltype, null, { createCollection: false });
|
|
3274
3816
|
}
|
|
3817
|
+
const { model } = typeInfo;
|
|
3818
|
+
if (model !== null && (typeof model === 'object' || typeof model === 'function')) generatedModels.add(model);
|
|
3275
3819
|
}
|
|
3276
3820
|
});
|
|
3277
3821
|
|
|
@@ -3316,7 +3860,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3316
3860
|
includedCustomQueries);
|
|
3317
3861
|
}
|
|
3318
3862
|
|
|
3319
|
-
const schema = constructSchema(query, mutation);
|
|
3863
|
+
const schema = constructSchema(query, mutation, reserveVersionNames);
|
|
3320
3864
|
// Reserve reachable types whose relation fields still have no resolver, such as an unregistered
|
|
3321
3865
|
// custom operation result, so another runtime cannot later generate resolvers this schema executes.
|
|
3322
3866
|
schemaTypes.forEach((type) => {
|
|
@@ -3324,6 +3868,10 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3324
3868
|
runtimeBoundTypes.set(type, runtimeIdentity);
|
|
3325
3869
|
}
|
|
3326
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
|
+
});
|
|
3327
3875
|
return schema;
|
|
3328
3876
|
};
|
|
3329
3877
|
|
|
@@ -3385,6 +3933,64 @@ const registerCustomQuery = (name, description, inputModel, outputModel, callbac
|
|
|
3385
3933
|
registeredQueries[name] = registration;
|
|
3386
3934
|
};
|
|
3387
3935
|
|
|
3936
|
+
const invalidView = (detail) => new SimfinityError(`registerView ${detail}`, 'INVALID_VIEW', 500);
|
|
3937
|
+
|
|
3938
|
+
// A view parameter is a value the client sends as a variable or a literal: a scalar, an enum or a list
|
|
3939
|
+
// of them, each possibly NonNull. An input object is never one, wherever it is nested.
|
|
3940
|
+
const isViewParamType = (type) => {
|
|
3941
|
+
if (isNonNullType(type) || isListType(type)) return isViewParamType(type.ofType);
|
|
3942
|
+
return isScalarType(type) || isEnumType(type);
|
|
3943
|
+
};
|
|
3944
|
+
|
|
3945
|
+
const isNonNullPlainObject = (value) => value !== null && typeof value === 'object' && isPlainObject(value);
|
|
3946
|
+
|
|
3947
|
+
const VIEW_OPTION_KEYS = new Set(['type', 'description', 'params', 'where']);
|
|
3948
|
+
const VIEW_PARAM_KEYS = new Set(['type', 'description', 'defaultValue']);
|
|
3949
|
+
|
|
3950
|
+
const validateViewParams = (params) => {
|
|
3951
|
+
if (params === undefined) return;
|
|
3952
|
+
if (!isNonNullPlainObject(params)) throw invalidView('params must be an object');
|
|
3953
|
+
for (const [paramName, param] of Object.entries(params)) {
|
|
3954
|
+
if (!isNonNullPlainObject(param)) throw invalidView(`param ${paramName} must be an object`);
|
|
3955
|
+
for (const key of Object.keys(param)) {
|
|
3956
|
+
if (!VIEW_PARAM_KEYS.has(key)) throw invalidView(`param ${paramName} has an unknown key ${key}`);
|
|
3957
|
+
}
|
|
3958
|
+
if (!isViewParamType(param.type)) {
|
|
3959
|
+
throw invalidView(`param ${paramName} type must be a scalar, an enum or a list of them, not an input object`);
|
|
3960
|
+
}
|
|
3961
|
+
if (param.description !== undefined && typeof param.description !== 'string') {
|
|
3962
|
+
throw invalidView(`param ${paramName} description must be a string`);
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
};
|
|
3966
|
+
|
|
3967
|
+
const validateViewRegistration = (name, options) => {
|
|
3968
|
+
if (typeof name !== 'string' || !/^[_A-Za-z][_0-9A-Za-z]*$/.test(name) || name.startsWith('__')) {
|
|
3969
|
+
throw invalidView('requires a valid GraphQL field name that does not start with __');
|
|
3970
|
+
}
|
|
3971
|
+
if (name.endsWith('_aggregate')) {
|
|
3972
|
+
throw invalidView(`name ${name} must not end with _aggregate, which the generated aggregate queries use`);
|
|
3973
|
+
}
|
|
3974
|
+
if (!isNonNullPlainObject(options)) throw invalidView('requires an options object');
|
|
3975
|
+
for (const key of Object.keys(options)) {
|
|
3976
|
+
if (!VIEW_OPTION_KEYS.has(key)) throw invalidView(`has an unknown option ${key}`);
|
|
3977
|
+
}
|
|
3978
|
+
if (!(options.type instanceof GraphQLObjectType)) throw invalidView('type must be a GraphQLObjectType');
|
|
3979
|
+
if (typeof options.description !== 'string' || options.description === '') {
|
|
3980
|
+
throw invalidView('requires a non-empty description');
|
|
3981
|
+
}
|
|
3982
|
+
validateViewParams(options.params);
|
|
3983
|
+
if (typeof options.where !== 'function') throw invalidView('where must be a function');
|
|
3984
|
+
};
|
|
3985
|
+
|
|
3986
|
+
const registerView = (name, options) => {
|
|
3987
|
+
validateViewRegistration(name, options);
|
|
3988
|
+
if (Object.hasOwn(registeredViews, name)) throw queryNameConflict(name);
|
|
3989
|
+
registeredViews[name] = {
|
|
3990
|
+
name, type: options.type, description: options.description, params: options.params ?? {}, where: options.where,
|
|
3991
|
+
};
|
|
3992
|
+
};
|
|
3993
|
+
|
|
3388
3994
|
// Gives the field a new extensions object as well as the resolver, so a copy made before keeps
|
|
3389
3995
|
// untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
|
|
3390
3996
|
// copying it, when a type is rebuilt from toConfig().
|
|
@@ -3429,14 +4035,7 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
3429
4035
|
if (!relatedTypeInfo || !relatedTypeInfo.model) {
|
|
3430
4036
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
3431
4037
|
}
|
|
3432
|
-
const
|
|
3433
|
-
const issued = markClientConstructs(assertQueryConditions(relatedType, args, 'find'));
|
|
3434
|
-
const params = { type: relatedTypeInfo, args, operation: 'find', context };
|
|
3435
|
-
await executeMiddleware(params);
|
|
3436
|
-
await executeScope(params);
|
|
3437
|
-
await applyJoinedScopes(joinedScopes, params);
|
|
3438
|
-
const filling = settleClientArgs(relatedType, params, 'find', issued);
|
|
3439
|
-
if (filling) await filling;
|
|
4038
|
+
const params = await prepareRead(relatedTypeInfo, args, context, 'find');
|
|
3440
4039
|
return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
|
|
3441
4040
|
}, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
|
|
3442
4041
|
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
@@ -3494,6 +4093,8 @@ const connect = (model, gqltype, simpleEntityEndpointName,
|
|
|
3494
4093
|
};
|
|
3495
4094
|
assertNotBoundElsewhere(gqltype);
|
|
3496
4095
|
validateScope(gqltype);
|
|
4096
|
+
// The declaration's shape only; it reads extensions, never the fields (#98).
|
|
4097
|
+
concurrencyDeclared(gqltype);
|
|
3497
4098
|
if (adapter.validateRegistration) adapter.validateRegistration(registration);
|
|
3498
4099
|
|
|
3499
4100
|
invalidateCollectionInputs(gqltype);
|
|
@@ -3524,6 +4125,7 @@ const addNoEndpointType = (gqltype) => {
|
|
|
3524
4125
|
gqltype, endpoint: false, model: null, needsModel,
|
|
3525
4126
|
};
|
|
3526
4127
|
validateScope(gqltype);
|
|
4128
|
+
concurrencyDeclared(gqltype);
|
|
3527
4129
|
if (adapter.validateRegistration) adapter.validateRegistration(registration);
|
|
3528
4130
|
|
|
3529
4131
|
invalidateCollectionInputs(gqltype);
|
|
@@ -3537,7 +4139,9 @@ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
|
|
|
3537
4139
|
|
|
3538
4140
|
// `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
|
|
3539
4141
|
// A field named like a generated argument gets no top-level filter either; it is reported once.
|
|
3540
|
-
// 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.
|
|
3541
4145
|
const createArgsForQuery = (gqltype, omit = null) => {
|
|
3542
4146
|
const argsObject = {};
|
|
3543
4147
|
const argTypes = gqltype.getFields();
|
|
@@ -3550,7 +4154,7 @@ const createArgsForQuery = (gqltype, omit = null) => {
|
|
|
3550
4154
|
}
|
|
3551
4155
|
|
|
3552
4156
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
3553
|
-
if (fieldEntryName === omit || isVirtualField(fieldEntry)) continue;
|
|
4157
|
+
if (fieldEntryName === omit || isVirtualField(fieldEntry) || isVersionField(fieldEntry)) continue;
|
|
3554
4158
|
let filterType = null;
|
|
3555
4159
|
|
|
3556
4160
|
if (fieldEntry.type instanceof GraphQLScalarType
|
|
@@ -3622,6 +4226,7 @@ function formatArgs(argsArray) {
|
|
|
3622
4226
|
use,
|
|
3623
4227
|
registerMutation,
|
|
3624
4228
|
registerCustomQuery,
|
|
4229
|
+
registerView,
|
|
3625
4230
|
saveObject,
|
|
3626
4231
|
preventCreatingCollection,
|
|
3627
4232
|
};
|