@simtlix/simfinity-core 3.15.0 → 3.17.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 +12 -4
- package/package.json +1 -1
- package/src/auth/index.js +97 -36
- package/src/auth/operation-entries.js +111 -0
- package/src/const/QLOperator.js +2 -0
- package/src/execution-state.js +71 -0
- package/src/metadata.js +2 -2
- package/src/mutation-limits.js +37 -7
- package/src/operation-input.js +345 -0
- package/src/query-comparisons.js +54 -0
- package/src/query-frames.js +160 -26
- package/src/query-operators.js +98 -10
- package/src/query-plan.js +94 -29
- package/src/runtime.js +723 -77
- package/src/schema-registrations.js +6 -1
- package/src/transaction-references.js +259 -0
- package/src/transaction-schema.js +163 -0
- package/types/index.d.ts +177 -19
package/src/runtime.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
configureQueryLimits, getQueryMaxPageSize, getQueryMaxReferenceFrames, paginationStages,
|
|
3
3
|
} from './query-limits.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
configureMutationLimits, getMaxNestedOperations, getMaxTransactionOperations, nestedOperationsExceededError,
|
|
6
|
+
transactionOperationsExceededError,
|
|
7
|
+
} from './mutation-limits.js';
|
|
5
8
|
import { createAfterCommit } from './after-commit.js';
|
|
6
9
|
import {
|
|
7
10
|
GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
|
|
8
11
|
GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
|
|
9
12
|
GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
|
|
10
|
-
Kind, getNamedType, isEnumType, isListType, isNonNullType, isObjectType, isOutputType,
|
|
13
|
+
Kind, defaultFieldResolver, getNamedType, isEnumType, isListType, isNonNullType, isObjectType, isOutputType,
|
|
14
|
+
isScalarType,
|
|
11
15
|
} from 'graphql';
|
|
12
16
|
|
|
13
17
|
import SimfinityError from './errors/simfinity.error.js';
|
|
@@ -22,12 +26,13 @@ import QLSort from './const/QLSort.js';
|
|
|
22
26
|
import {
|
|
23
27
|
collectQueryOperators, collectQueryPathEntries, walkQueryPath,
|
|
24
28
|
} from './query-plan.js';
|
|
25
|
-
import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
29
|
+
import { QUERY_OPERATORS, hasValuePath, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
26
30
|
import {
|
|
27
31
|
assertEmptinessPath, describeEmptiness, emptinessScopeTargets, isEmptinessOperator,
|
|
28
32
|
} from './query-emptiness.js';
|
|
29
33
|
import { relationFieldOwners } from './relation-owners.js';
|
|
30
|
-
import { isThenable } from './auth/thenable.js';
|
|
34
|
+
import { discardThenable, isThenable } from './auth/thenable.js';
|
|
35
|
+
import { authorizeOperation } from './auth/operation-entries.js';
|
|
31
36
|
import {
|
|
32
37
|
assertPlainConnectionField, getFieldStorageName, getListShape, isVirtualField, markStoredIdentity, normalizeConnectionField,
|
|
33
38
|
} from './relation-storage.js';
|
|
@@ -43,6 +48,16 @@ import {
|
|
|
43
48
|
unexpectedDeleteError, versionConflictError, versionConnectionCollision, versionEmbeddedMisuse, versionExhaustedError,
|
|
44
49
|
versionFieldCollision, versionManagedError,
|
|
45
50
|
} from './record-version.js';
|
|
51
|
+
import {
|
|
52
|
+
attachExecutionState, countNestedEntry, createExecutionState, detachExecutionState, executionStateOf,
|
|
53
|
+
remainingNestedBudget,
|
|
54
|
+
} from './execution-state.js';
|
|
55
|
+
import { readDeleteTarget, snapshotOperationInput } from './operation-input.js';
|
|
56
|
+
import {
|
|
57
|
+
assertTransactionSchema, buildTransactionField, collectOperationFields, invalidTransactionOption, noOperationError,
|
|
58
|
+
readTransactionOption, recordTransactionState, transactionUnsupportedError, withTransactionField,
|
|
59
|
+
} from './transaction-schema.js';
|
|
60
|
+
import { planReferences, resolveReferences } from './transaction-references.js';
|
|
46
61
|
|
|
47
62
|
// Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
|
|
48
63
|
// created is application code, such as a masking resolver, recorded once per type so later
|
|
@@ -195,6 +210,7 @@ const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
|
|
|
195
210
|
field: `${prefix}.${condition?.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition?.field}`,
|
|
196
211
|
operator: condition?.operator,
|
|
197
212
|
value: condition?.value,
|
|
213
|
+
...(hasValuePath(condition) ? { valuePath: `${prefix}.${condition.valuePath}` } : {}),
|
|
198
214
|
}));
|
|
199
215
|
}
|
|
200
216
|
if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
|
|
@@ -228,9 +244,13 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
|
|
|
228
244
|
// An emptiness term without a path (omitted, null or "") tests the field itself; other
|
|
229
245
|
// operators keep the path as written.
|
|
230
246
|
const itself = isEmptinessOperator(term?.operator) && (term.path == null || term.path === '');
|
|
231
|
-
conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value
|
|
247
|
+
conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value,
|
|
248
|
+
...(hasValuePath(term) ? { valuePath: `${prefix}.${name}.${term.valuePath}` } : {}),
|
|
249
|
+
});
|
|
232
250
|
}
|
|
233
|
-
} else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value
|
|
251
|
+
} else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value,
|
|
252
|
+
...(hasValuePath(value) ? { valuePath: `${prefix}.${value.valuePath}` } : {}),
|
|
253
|
+
});
|
|
234
254
|
}
|
|
235
255
|
return conditions.length ? [{ conditions }, ...groups] : groups;
|
|
236
256
|
};
|
|
@@ -430,14 +450,21 @@ const classifyValue = (value) => {
|
|
|
430
450
|
: new InternalServerError('Unexpected error value');
|
|
431
451
|
};
|
|
432
452
|
|
|
453
|
+
// A value thrown while executing a field. graphql-js raises its own code-less GraphQLErrors there too
|
|
454
|
+
// when it cannot complete a value (scalar or enum serialization, isTypeOf, abstract type resolution, a
|
|
455
|
+
// non-iterable list), and some print the value. So a GraphQLError raised there is an application error
|
|
456
|
+
// only with its own code or a SimfinityError cause; any other is unexpected.
|
|
457
|
+
const classifyThrown = (thrown) => {
|
|
458
|
+
if (thrown instanceof GraphQLError && typeof thrown.extensions?.code !== 'string' && !simfinityCauseOf(thrown)) {
|
|
459
|
+
return new InternalServerError(thrown.message, thrown);
|
|
460
|
+
}
|
|
461
|
+
return classifyValue(thrown);
|
|
462
|
+
};
|
|
463
|
+
|
|
433
464
|
// Errors raised while executing a field have a path; request errors (syntax, validation, variable
|
|
434
465
|
// coercion) never do. GraphQL servers pass the GraphQLError that graphql-js created around the value
|
|
435
466
|
// a resolver threw, and that value is classified, without unwrapping a GraphQLError the resolver
|
|
436
467
|
// threw any further. A thrown GraphQLError that already has a path is passed as is.
|
|
437
|
-
// graphql-js raises its own code-less GraphQLErrors there too when it cannot complete a value (scalar
|
|
438
|
-
// or enum serialization, isTypeOf, abstract type resolution, a non-iterable list), and some print the
|
|
439
|
-
// value. So a GraphQLError raised there is an application error only with its own code or a
|
|
440
|
-
// SimfinityError cause; any other is unexpected.
|
|
441
468
|
const classifyError = (error) => {
|
|
442
469
|
if (!(error instanceof GraphQLError)) return classifyValue(error);
|
|
443
470
|
if (!Array.isArray(error.path)) {
|
|
@@ -447,11 +474,33 @@ const classifyError = (error) => {
|
|
|
447
474
|
const cause = simfinityCauseOf(error);
|
|
448
475
|
return cause instanceof InternalServerError ? cause : classifyValue(error);
|
|
449
476
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
477
|
+
return classifyThrown(isFieldErrorWrapper(error) ? thrownValueOf(error.originalError) : error);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// The error that a transaction field reports when the operation `operation`, at `operationIndex`, ended
|
|
481
|
+
// it (#151): a new error with the code, status and message that buildErrorFormatter gives
|
|
482
|
+
// `cause` when a root resolver throws it, and every extension of it, plus the operation's position. An
|
|
483
|
+
// InternalServerError stays one, as for a root, so the callback that masks unexpected errors still hides
|
|
484
|
+
// the message of a cause that is not an application error. The original is its non-enumerable `cause`,
|
|
485
|
+
// out of every copy of the error, and is never changed: it may be frozen, or shared. `getCause()` of an
|
|
486
|
+
// InternalServerError does not return a value that is not an Error, as for a root. Any other decoration
|
|
487
|
+
// of a cause that has a `getCause()`, such as the DATABASE_ERROR and TRANSACTION_RETRY_EXCEEDED errors the
|
|
488
|
+
// adapters build, keeps it: it returns what the cause's own returns, the driver error.
|
|
489
|
+
const decorateOperationError = (cause, operationIndex, operation) => {
|
|
490
|
+
const classified = classifyThrown(cause);
|
|
491
|
+
const decorated = classified instanceof InternalServerError
|
|
492
|
+
? new InternalServerError(classified.message)
|
|
493
|
+
: new SimfinityError(classified.message, classified.extensions?.code, classified.extensions?.status);
|
|
494
|
+
decorated.extensions = { ...classified.extensions, operationIndex, operation };
|
|
495
|
+
Object.defineProperty(decorated, 'cause', {
|
|
496
|
+
value: cause, writable: true, configurable: true, enumerable: false,
|
|
497
|
+
});
|
|
498
|
+
if (decorated instanceof InternalServerError) {
|
|
499
|
+
if (!(cause instanceof Error)) decorated.getCause = () => undefined;
|
|
500
|
+
} else if (typeof cause?.getCause === 'function') {
|
|
501
|
+
decorated.getCause = () => cause.getCause();
|
|
453
502
|
}
|
|
454
|
-
return
|
|
503
|
+
return decorated;
|
|
455
504
|
};
|
|
456
505
|
|
|
457
506
|
export const buildErrorFormatter = (callback) => (error) => {
|
|
@@ -555,7 +604,7 @@ export const createRuntime = (adapter) => {
|
|
|
555
604
|
// IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
|
|
556
605
|
// conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
|
|
557
606
|
const {
|
|
558
|
-
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers,
|
|
607
|
+
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers, valuePath: supportsValuePath,
|
|
559
608
|
} = resolveQueryFeatures(adapter.queryFeatures);
|
|
560
609
|
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
561
610
|
// The write features the adapter implements, read once: with optimisticConcurrency, schemas may
|
|
@@ -564,12 +613,19 @@ export const createRuntime = (adapter) => {
|
|
|
564
613
|
|
|
565
614
|
const stateValue = adapter.stateValue || ((state) => state.name);
|
|
566
615
|
const runtimeIdentity = {};
|
|
616
|
+
// The identity that execution states (execution-state.js) compare, so that a session lent to another
|
|
617
|
+
// runtime finds no state.
|
|
618
|
+
const runtimeToken = {};
|
|
567
619
|
const typesDict = { types: {} };
|
|
568
620
|
const waitingInputType = {};
|
|
569
621
|
const typesDictForUpdate = { types: {} };
|
|
570
622
|
// `added` item inputs of referenced collections by generated name, so collections that reach the
|
|
571
623
|
// same child through the same connectionField share one input type.
|
|
572
624
|
const excludedFieldInputTypes = new Map();
|
|
625
|
+
// The operation inputs of referenced collections (the types with `added`, `updated` and `deleted`
|
|
626
|
+
// that createOneToManyInputType builds). The typed input walk (operation-input.js) knows them by
|
|
627
|
+
// identity, to find the record keys inside them: `updated[].id` and each `deleted` item.
|
|
628
|
+
const collectionInputTypes = new WeakSet();
|
|
573
629
|
// Self-referencing collection field name -> the types whose collection inputs were built with the
|
|
574
630
|
// unqualified names OneToManyA<field> and OneToManyU<field>; type -> its self-referencing
|
|
575
631
|
// collections whose inputs are named after the type instead (see claimSelfCollectionNames).
|
|
@@ -684,6 +740,7 @@ const QLFilter = new GraphQLInputObjectType({
|
|
|
684
740
|
fields: () => ({
|
|
685
741
|
operator: { type: QLOperator },
|
|
686
742
|
value: { type: QLValue },
|
|
743
|
+
valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
|
|
687
744
|
}),
|
|
688
745
|
});
|
|
689
746
|
|
|
@@ -692,6 +749,7 @@ const QLTypeFilter = new GraphQLInputObjectType({
|
|
|
692
749
|
fields: () => ({
|
|
693
750
|
operator: { type: QLOperator },
|
|
694
751
|
value: { type: QLValue },
|
|
752
|
+
valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
|
|
695
753
|
path: { type: GraphQLString },
|
|
696
754
|
}),
|
|
697
755
|
});
|
|
@@ -717,6 +775,7 @@ const QLFilterCondition = new GraphQLInputObjectType({
|
|
|
717
775
|
field: { type: new GraphQLNonNull(GraphQLString) },
|
|
718
776
|
operator: { type: QLOperator },
|
|
719
777
|
value: { type: QLValue },
|
|
778
|
+
valuePath: { type: GraphQLString, description: 'Compare with a stored field path; mutually exclusive with value.' },
|
|
720
779
|
path: { type: GraphQLString },
|
|
721
780
|
}),
|
|
722
781
|
});
|
|
@@ -906,7 +965,7 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
|
|
|
906
965
|
+ `${itemType.name} records with a registered mutation.`,
|
|
907
966
|
};
|
|
908
967
|
|
|
909
|
-
|
|
968
|
+
const collectionInput = new GraphQLInputObjectType({
|
|
910
969
|
name,
|
|
911
970
|
fields: () => {
|
|
912
971
|
const fields = {};
|
|
@@ -929,6 +988,8 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
|
|
|
929
988
|
return fields;
|
|
930
989
|
},
|
|
931
990
|
});
|
|
991
|
+
collectionInputTypes.add(collectionInput);
|
|
992
|
+
return collectionInput;
|
|
932
993
|
};
|
|
933
994
|
|
|
934
995
|
// What graphQLListInputType returns for a list of an embedded type whose input has no fields.
|
|
@@ -1779,17 +1840,144 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
|
|
|
1779
1840
|
return result;
|
|
1780
1841
|
};
|
|
1781
1842
|
|
|
1843
|
+
// saveObject() and the lifecycle helpers look up, at entry, the execution state that a transaction
|
|
1844
|
+
// attempt (#151) attached to the session they were given. When the adapter's withTransaction hands the
|
|
1845
|
+
// body another object than that session, the state follows the body's object for as long as the body
|
|
1846
|
+
// runs, so every nested entry the body runs counts against the attempt's budget, whatever object the
|
|
1847
|
+
// adapter uses. Attachments are counted (execution-state.js): an object that already carries the state,
|
|
1848
|
+
// from an enclosing body or from a body running at the same time, keeps it until the last of them ends,
|
|
1849
|
+
// and one that carries another state gets that state back when this body ends, as long as the bodies on
|
|
1850
|
+
// that object end in the reverse order they started (nested, or one at a time). When bodies of different
|
|
1851
|
+
// states interleave on one object, a body that ends releases the latest attachment of its state, which
|
|
1852
|
+
// another body may have made, so the object can carry another state while a body of this one still runs.
|
|
1853
|
+
// Nothing is attached without a state, nor to an object that is not one.
|
|
1854
|
+
const runInExecutionState = (state, session, bodySession, run) => {
|
|
1855
|
+
if (state === undefined || bodySession === session || bodySession === null
|
|
1856
|
+
|| (typeof bodySession !== 'object' && typeof bodySession !== 'function')) {
|
|
1857
|
+
return run();
|
|
1858
|
+
}
|
|
1859
|
+
attachExecutionState(bodySession, state);
|
|
1860
|
+
return (async () => {
|
|
1861
|
+
try {
|
|
1862
|
+
return await run();
|
|
1863
|
+
} finally {
|
|
1864
|
+
detachExecutionState(bodySession, state);
|
|
1865
|
+
}
|
|
1866
|
+
})();
|
|
1867
|
+
};
|
|
1868
|
+
|
|
1782
1869
|
const saveObject = async (typeName, args, session, context) => {
|
|
1783
1870
|
const type = typesDict.types[typeName];
|
|
1784
1871
|
assertCollectionWrites(type.gqltype, args);
|
|
1785
1872
|
const snapshot = cloneInput(args, type.inputType);
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1873
|
+
const state = executionStateOf(session, runtimeToken);
|
|
1874
|
+
return adapter.withTransaction(session, (transaction) => runInExecutionState(state, session, transaction,
|
|
1875
|
+
() => runOperation({ Model: type.model, gqltype: type.gqltype, controller: type.controller }, operations.SAVE,
|
|
1876
|
+
cloneInput(snapshot, type.inputType), transaction, context)), type.model);
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
// The lifecycle helpers (#151): updateObject, deleteObject and transitionObject run the lifecycle of the
|
|
1880
|
+
// generated update, delete and state action mutations from code, through runOperation, as those roots
|
|
1881
|
+
// do, but without the root record's global middleware and without GraphQL. They check their target, then
|
|
1882
|
+
// validate the caller's input and take a snapshot of it before anything runs; every attempt of the
|
|
1883
|
+
// transaction works on its own copy of that snapshot, and the caller's input is never written to.
|
|
1884
|
+
|
|
1885
|
+
// How a type or action name that is not a string appears in a message, which never shows a value.
|
|
1886
|
+
const operationName = (value) => (typeof value === 'string' ? value : `(${value === null ? 'null' : typeof value})`);
|
|
1887
|
+
const notStoredTargetError = (typeName) => new SimfinityError(
|
|
1888
|
+
`Type ${operationName(typeName)} is not a stored record type registered in this runtime`, 'INVALID_OPERATION_TARGET', 500,
|
|
1889
|
+
);
|
|
1890
|
+
const noUpdateInputError = (typeName) => new SimfinityError(
|
|
1891
|
+
`Type ${typeName} has no prepared update input with an id field`, 'INVALID_OPERATION_TARGET', 500,
|
|
1892
|
+
);
|
|
1893
|
+
|
|
1894
|
+
// The registration a helper works on: a type registered in this runtime that stores records of its own
|
|
1895
|
+
// and has its model. An update or a transition also needs the type's generated update input, which only
|
|
1896
|
+
// createSchema() builds, with an `id` field, which a readOnly id or an input without fields (#161) lacks;
|
|
1897
|
+
// it gets the update registration, which holds that input. createSchema() also decides which types store
|
|
1898
|
+
// records, so a missing input is reported first: before createSchema(), the error names what is missing.
|
|
1899
|
+
const resolveOperationTarget = (typeName, { update }) => {
|
|
1900
|
+
const registration = typeof typeName === 'string' && Object.hasOwn(typesDict.types, typeName)
|
|
1901
|
+
? typesDict.types[typeName] : undefined;
|
|
1902
|
+
if (!registration) throw notStoredTargetError(typeName);
|
|
1903
|
+
const updateRegistration = typesDictForUpdate.types[typeName];
|
|
1904
|
+
if (update && !updateRegistration?.inputType) throw noUpdateInputError(typeName);
|
|
1905
|
+
if (!registration.model || registration.storedIdentity !== true) throw notStoredTargetError(typeName);
|
|
1906
|
+
if (update && !Object.hasOwn(updateRegistration.inputType.getFields(), 'id')) throw noUpdateInputError(typeName);
|
|
1907
|
+
return update ? updateRegistration : registration;
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
// The input walk (operation-input.js) knows these inputs by identity, to find the record keys inside them:
|
|
1911
|
+
// `updated[].id` and each `deleted` item of the generated collection inputs, the id of a single reference
|
|
1912
|
+
// and the id of a versioned nested delete.
|
|
1913
|
+
const operationWalkOptions = { collectionInputTypes, idInputType: IdInputType, recordVersionRef: RecordVersionRef };
|
|
1914
|
+
|
|
1915
|
+
// The look-ahead of a transaction attempt (#151): the nested entries of `input` against what the attempt
|
|
1916
|
+
// has left of the limit it snapshotted, without counting them. No limit, no check.
|
|
1917
|
+
const assertRemainingNestedBudget = (gqltype, input, state) => {
|
|
1918
|
+
const remaining = remainingNestedBudget(state);
|
|
1919
|
+
if (remaining === null) return;
|
|
1920
|
+
const limit = state.limits.maxNestedOperations;
|
|
1921
|
+
assertNestedOperations(gqltype, input, limit, { count: limit - remaining });
|
|
1922
|
+
};
|
|
1923
|
+
|
|
1924
|
+
// The nested budget of a helper call. Outside a transaction attempt, the preflight is the whole budget,
|
|
1925
|
+
// with the limit configured when the helper is called. Inside one, it is the attempt's look-ahead.
|
|
1926
|
+
// Either way it only looks ahead and counts nothing.
|
|
1927
|
+
const assertHelperNestedBudget = (gqltype, snapshot, state) => {
|
|
1928
|
+
if (state !== undefined) {
|
|
1929
|
+
assertRemainingNestedBudget(gqltype, snapshot, state);
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
const limit = getMaxNestedOperations();
|
|
1933
|
+
if (limit !== null) assertNestedOperations(gqltype, snapshot, limit);
|
|
1934
|
+
};
|
|
1935
|
+
|
|
1936
|
+
// The transaction of a helper: the adapter's own when no session is supplied, retries included; with a
|
|
1937
|
+
// session, the adapter checks it, and the helper neither commits, retries, aborts nor cleans up.
|
|
1938
|
+
// `attemptArgs` gives each attempt its own arguments.
|
|
1939
|
+
const runHelperTransaction = (type, operation, attemptArgs, session, state, context, actionField = null, actionName) => adapter
|
|
1940
|
+
.withTransaction(session ?? null, (bodySession) => runInExecutionState(state, session, bodySession,
|
|
1941
|
+
() => runOperation({ Model: type.model, gqltype: type.gqltype, controller: type.controller }, operation,
|
|
1942
|
+
attemptArgs(), bodySession, context, actionField, actionName)), type.model);
|
|
1943
|
+
|
|
1944
|
+
// An update or a transition: the update input checks the whole input, whose top-level id is required and
|
|
1945
|
+
// whose state, when a state machine manages it, only a transition changes. The nested preflight and the
|
|
1946
|
+
// collection check run on the snapshot, before the transaction.
|
|
1947
|
+
const runHelperUpdate = (type, operation, input, session, context, actionField = null, actionName) => {
|
|
1948
|
+
const state = executionStateOf(session, runtimeToken);
|
|
1949
|
+
const snapshot = snapshotOperationInput(input, type.inputType, {
|
|
1950
|
+
...operationWalkOptions, mode: 'programmatic', path: 'input', topLevelId: true, stateManaged: !!type.stateMachine,
|
|
1951
|
+
});
|
|
1952
|
+
assertHelperNestedBudget(type.gqltype, snapshot, state);
|
|
1953
|
+
assertCollectionWrites(type.gqltype, snapshot);
|
|
1954
|
+
const attemptArgs = () => snapshotOperationInput(snapshot, type.inputType, { ...operationWalkOptions, mode: 'copy' });
|
|
1955
|
+
return runHelperTransaction(type, operation, attemptArgs, session, state, context, actionField, actionName);
|
|
1956
|
+
};
|
|
1957
|
+
|
|
1958
|
+
const updateObject = async (typeName, input, session, context) => runHelperUpdate(
|
|
1959
|
+
resolveOperationTarget(typeName, { update: true }), operations.UPDATE, input, session, context,
|
|
1960
|
+
);
|
|
1961
|
+
|
|
1962
|
+
const transitionObject = async (typeName, actionName, input, session, context) => {
|
|
1963
|
+
const type = resolveOperationTarget(typeName, { update: true });
|
|
1964
|
+
const actions = type.stateMachine?.actions;
|
|
1965
|
+
if (typeof actionName !== 'string' || !actions || !Object.hasOwn(actions, actionName)) {
|
|
1966
|
+
throw new SimfinityError(`Type ${typeName} has no state machine action ${operationName(actionName)}`,
|
|
1967
|
+
'UNKNOWN_ACTION', 500);
|
|
1968
|
+
}
|
|
1969
|
+
return runHelperUpdate(type, operations.STATE_CHANGED, input, session, context, actions[actionName], actionName);
|
|
1970
|
+
};
|
|
1971
|
+
|
|
1972
|
+
// The target is { id } or, for a protected type, { id, version }; the delete takes the bare ID or a new
|
|
1973
|
+
// { id, version } on each attempt, as the delete root passes them. A delete has no nested entries.
|
|
1974
|
+
const deleteObject = async (typeName, target, session, context) => {
|
|
1975
|
+
const type = resolveOperationTarget(typeName, { update: false });
|
|
1976
|
+
const versioned = isProtected(type.gqltype);
|
|
1977
|
+
const state = executionStateOf(session, runtimeToken);
|
|
1978
|
+
const { id, version } = readDeleteTarget(target, { protectedType: versioned, path: 'input' });
|
|
1979
|
+
const attemptArgs = versioned ? () => ({ id, version }) : () => id;
|
|
1980
|
+
return runHelperTransaction(type, operations.DELETE, attemptArgs, session, state, context);
|
|
1793
1981
|
};
|
|
1794
1982
|
|
|
1795
1983
|
// Counts the added, updated and deleted entries of referenced collections, at every nesting level that
|
|
@@ -1805,10 +1993,7 @@ const assertNestedOperations = (gqltype, input, limit, state = { count: 0 }) =>
|
|
|
1805
1993
|
for (const key of ['added', 'updated', 'deleted']) {
|
|
1806
1994
|
if (!Array.isArray(value[key])) continue;
|
|
1807
1995
|
state.count += value[key].length;
|
|
1808
|
-
if (state.count > limit)
|
|
1809
|
-
throw new SimfinityError(`A mutation may perform at most ${limit} nested collection operations`,
|
|
1810
|
-
'NESTED_OPERATIONS_EXCEEDED', 400);
|
|
1811
|
-
}
|
|
1996
|
+
if (state.count > limit) throw nestedOperationsExceededError(limit);
|
|
1812
1997
|
if (key !== 'deleted') {
|
|
1813
1998
|
for (const item of value[key]) assertNestedOperations(listShape.itemType, item, limit, state);
|
|
1814
1999
|
}
|
|
@@ -1867,6 +2052,44 @@ const preReadTopLevel = async (Model, gqltype, { id, version }, advances, contex
|
|
|
1867
2052
|
return { expectedVersion, observedVersion, record };
|
|
1868
2053
|
};
|
|
1869
2054
|
|
|
2055
|
+
// Runs one operation on a session that already exists: for the roots, through executeOperation, and for
|
|
2056
|
+
// the lifecycle helpers and the generated transaction (#151). A delete takes the bare ID, or
|
|
2057
|
+
// { id, version } for a protected type; every other operation takes its attempt's copy of the input.
|
|
2058
|
+
// The runner captures the version precondition from those args: an update or delete through
|
|
2059
|
+
// preReadTopLevel, a transition through onStateChanged. No caller captures it a second time.
|
|
2060
|
+
const runOperation = async ({ Model, gqltype, controller }, operation, attemptArgs, session, context,
|
|
2061
|
+
actionField = null, actionName) => {
|
|
2062
|
+
let record;
|
|
2063
|
+
switch (operation) {
|
|
2064
|
+
case operations.SAVE:
|
|
2065
|
+
record = await onSaveObject(Model, gqltype, controller, attemptArgs, session, null, context);
|
|
2066
|
+
break;
|
|
2067
|
+
case operations.UPDATE: {
|
|
2068
|
+
const precondition = isProtected(gqltype)
|
|
2069
|
+
? await preReadTopLevel(Model, gqltype, attemptArgs, true, context, session) : undefined;
|
|
2070
|
+
record = await onUpdateSubject(Model, gqltype, controller, attemptArgs, session, null, context, false, precondition);
|
|
2071
|
+
break;
|
|
2072
|
+
}
|
|
2073
|
+
case operations.DELETE: {
|
|
2074
|
+
if (!isProtected(gqltype)) record = await onDelete(Model, controller, attemptArgs, session, context);
|
|
2075
|
+
else {
|
|
2076
|
+
const { expectedVersion, record: current } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
|
|
2077
|
+
session);
|
|
2078
|
+
record = await onDelete(Model, controller, attemptArgs.id, session, context, current, { gqltype, expectedVersion });
|
|
2079
|
+
}
|
|
2080
|
+
break;
|
|
2081
|
+
}
|
|
2082
|
+
case operations.STATE_CHANGED:
|
|
2083
|
+
record = await onStateChanged(Model, gqltype, controller, attemptArgs, session, actionField, context);
|
|
2084
|
+
break;
|
|
2085
|
+
default:
|
|
2086
|
+
return null;
|
|
2087
|
+
}
|
|
2088
|
+
afterCommit.queue({ typeName: gqltype.name, controller, operation, record, args: attemptArgs,
|
|
2089
|
+
context, session, action: actionName });
|
|
2090
|
+
return record;
|
|
2091
|
+
};
|
|
2092
|
+
|
|
1870
2093
|
// A delete passes the ID, or { id, version } for a protected type, which each attempt copies; every
|
|
1871
2094
|
// other operation passes its input, which attemptInputs copies. Either way, a retry checks the version
|
|
1872
2095
|
// the request sent.
|
|
@@ -1881,39 +2104,8 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
|
|
|
1881
2104
|
else if (versioned) nextInput = () => ({ ...args });
|
|
1882
2105
|
return adapter.withTransaction(
|
|
1883
2106
|
session,
|
|
1884
|
-
async (mySession) => {
|
|
1885
|
-
|
|
1886
|
-
let record;
|
|
1887
|
-
switch (operation) {
|
|
1888
|
-
case operations.SAVE:
|
|
1889
|
-
record = await onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
|
|
1890
|
-
break;
|
|
1891
|
-
case operations.UPDATE: {
|
|
1892
|
-
const precondition = versioned
|
|
1893
|
-
? await preReadTopLevel(Model, gqltype, attemptArgs, true, context, mySession) : undefined;
|
|
1894
|
-
record = await onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context, false, precondition);
|
|
1895
|
-
break;
|
|
1896
|
-
}
|
|
1897
|
-
case operations.DELETE: {
|
|
1898
|
-
if (!versioned) record = await onDelete(Model, controller, attemptArgs, mySession, context);
|
|
1899
|
-
else {
|
|
1900
|
-
const { expectedVersion, record: current } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
|
|
1901
|
-
mySession);
|
|
1902
|
-
record = await onDelete(Model, controller, attemptArgs.id, mySession, context, current, { gqltype, expectedVersion });
|
|
1903
|
-
}
|
|
1904
|
-
break;
|
|
1905
|
-
}
|
|
1906
|
-
case operations.STATE_CHANGED:
|
|
1907
|
-
record = await onStateChanged(Model, gqltype, controller,
|
|
1908
|
-
attemptArgs, mySession, actionField, context);
|
|
1909
|
-
break;
|
|
1910
|
-
default:
|
|
1911
|
-
return null;
|
|
1912
|
-
}
|
|
1913
|
-
afterCommit.queue({ typeName: gqltype.name, controller, operation, record, args: attemptArgs,
|
|
1914
|
-
context, session: mySession, action: actionName });
|
|
1915
|
-
return record;
|
|
1916
|
-
},
|
|
2107
|
+
async (mySession) => runOperation({ Model, gqltype, controller }, operation, nextInput(), mySession, context,
|
|
2108
|
+
actionField, actionName),
|
|
1917
2109
|
Model,
|
|
1918
2110
|
);
|
|
1919
2111
|
};
|
|
@@ -1942,7 +2134,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
|
|
|
1942
2134
|
// A protected child is updated or deleted at the version its entry carries (#98). A deleted entry is
|
|
1943
2135
|
// { id, version } for item middleware; one that is not an object, such as a string ID, has no version.
|
|
1944
2136
|
const versioned = isProtected(collectionGQLType) && operationType !== operations.SAVE;
|
|
2137
|
+
// On the session of a transaction attempt (#151), every entry counts against the attempt's joint
|
|
2138
|
+
// budget before it runs, a null one included; elsewhere nothing is counted.
|
|
2139
|
+
const executionState = executionStateOf(session, runtimeToken);
|
|
1945
2140
|
for (const element of collectionFieldsList) {
|
|
2141
|
+
if (executionState !== undefined) countNestedEntry(executionState);
|
|
1946
2142
|
if (element === null) continue;
|
|
1947
2143
|
let args = { input: element };
|
|
1948
2144
|
if (operationType === operations.DELETE) {
|
|
@@ -2103,13 +2299,13 @@ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true }
|
|
|
2103
2299
|
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
2104
2300
|
const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
|
|
2105
2301
|
for (const {
|
|
2106
|
-
segments, group, member, operator, use, frames, quantifier,
|
|
2302
|
+
segments, group, member, operator, use, frames, quantifier, comparison,
|
|
2107
2303
|
} of entries) {
|
|
2108
2304
|
const filter = use === 'filter';
|
|
2109
2305
|
const relations = filter && supportsQuantifiers ? pathRelations(gqltype, segments) : [];
|
|
2110
2306
|
const cut = relations.find((relation) => relation.collection && !J.has(relation.path));
|
|
2111
2307
|
const anchored = cut && relations.some((relation) => relation.collection && J.has(relation.path));
|
|
2112
|
-
const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
|
|
2308
|
+
const joins = !member && !comparison && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
|
|
2113
2309
|
&& !(filter && (frames.length || quantifier || (cut && !anchored)));
|
|
2114
2310
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
2115
2311
|
const path = segments.slice(0, index + 1).join('.');
|
|
@@ -2212,7 +2408,7 @@ const assertEmptinessForm = (gqltype, {
|
|
|
2212
2408
|
// carriers, which do not become predicates but must not use known undeclared capabilities.
|
|
2213
2409
|
const queryFrameContext = (gqltype, args, operation) => ({
|
|
2214
2410
|
gqltype, operation, quantifiers: supportsQuantifiers, referenceEmptiness: supportsReferenceEmptiness,
|
|
2215
|
-
operators: supportedOperators,
|
|
2411
|
+
operators: supportedOperators, valuePath: supportsValuePath,
|
|
2216
2412
|
J: operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set(),
|
|
2217
2413
|
scopeMarkers: 'pending',
|
|
2218
2414
|
});
|
|
@@ -2426,8 +2622,9 @@ const markClientConstructs = (checked, { aggregation, issued = new Map() } = {})
|
|
|
2426
2622
|
};
|
|
2427
2623
|
for (const { entry, shape } of conditions) {
|
|
2428
2624
|
const { construct } = entry;
|
|
2429
|
-
if (!construct || !(shape || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
|
|
2430
|
-
const
|
|
2625
|
+
if (!construct || !(shape || entry.hasValuePath || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
|
|
2626
|
+
const comparisonPath = entry.hasValuePath ? [...entry.segments.slice(0, -entry.relative.length), ...entry.valuePath.split('.')] : [];
|
|
2627
|
+
const targets = [...pathRelations(gqltype, entry.segments), ...pathRelations(gqltype, comparisonPath)].map(({ typeName }) => typeName);
|
|
2431
2628
|
if (shape) targets.push(...Object.values(emptinessScopeTargets(shape, entry.relative)));
|
|
2432
2629
|
if (!targets.some((name) => getScope(typesDict.types[name]?.gqltype, 'find'))) continue;
|
|
2433
2630
|
const origin = { operator: entry.operator ?? entry.quantifier, path: entry.path };
|
|
@@ -2470,7 +2667,8 @@ const assertClientMarkers = (gqltype, args, operation, issued, clientJ) => {
|
|
|
2470
2667
|
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is inside a trusted frame`);
|
|
2471
2668
|
}
|
|
2472
2669
|
if (supportsQuantifiers && !entry.frames.length && entry.form !== 'quantified' && !isEmptinessOperator(operator)) {
|
|
2473
|
-
|
|
2670
|
+
const operandPaths = [entry.segments, ...(entry.hasValuePath ? [[...entry.segments.slice(0, -entry.relative.length), ...entry.valuePath.split('.')]] : [])];
|
|
2671
|
+
for (const relation of operandPaths.flatMap((parts) => pathRelations(gqltype, parts))) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
|
|
2474
2672
|
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} changed aggregate join prefixes`);
|
|
2475
2673
|
}
|
|
2476
2674
|
}
|
|
@@ -2547,7 +2745,11 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2547
2745
|
const vectors = [];
|
|
2548
2746
|
for (const { entry, shape, type } of positions) {
|
|
2549
2747
|
const relations = pathRelations(type, entry.relative);
|
|
2550
|
-
if (
|
|
2748
|
+
if (entry.hasValuePath) {
|
|
2749
|
+
const existing = new Set(relations.map(({ path }) => path));
|
|
2750
|
+
relations.push(...pathRelations(type, entry.valuePath.split('.')).filter(({ path }) => !existing.has(path)));
|
|
2751
|
+
}
|
|
2752
|
+
if (!shape && !entry.hasValuePath && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
|
|
2551
2753
|
const relation = relationOnPath(gqltype, entry.segments);
|
|
2552
2754
|
if (relation) throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
|
|
2553
2755
|
+ `${entry.path}, which reads through ${relation} without its scope`);
|
|
@@ -2556,7 +2758,7 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2556
2758
|
const anchored = rootShorthand && relations.some(({ collection, path }) => collection && J.has(path));
|
|
2557
2759
|
const cut = anchored ? relations.find(({ collection, path }) => collection && !J.has(path)) : null;
|
|
2558
2760
|
const keys = shape ? emptinessScopeTargets(shape, entry.relative) : Object.fromEntries(relations
|
|
2559
|
-
.filter((relation) => !anchored || relation.collection || (cut && relation.index >= cut.index))
|
|
2761
|
+
.filter((relation) => entry.hasValuePath || !anchored || relation.collection || (cut && relation.index >= cut.index))
|
|
2560
2762
|
.map(({ path, typeName, collection }) => [path, rootShorthand && collection && J.has(path) ? null : typeName]));
|
|
2561
2763
|
for (const [key, target] of Object.entries(keys)) {
|
|
2562
2764
|
if (Object.hasOwn(targets, key) && targets[key] !== target) {
|
|
@@ -3059,8 +3261,9 @@ const buildRegisteredOperationField = (entry, registration, operation) => {
|
|
|
3059
3261
|
// classify it without guessing from its name. Extensions do not appear in SDL or introspection.
|
|
3060
3262
|
// An input object needs a field, so a type whose create input has none gets no add mutation, and
|
|
3061
3263
|
// one whose update input has none, which only a type without a writable id can have, gets no update
|
|
3062
|
-
// mutation or state machine actions either (#161).
|
|
3063
|
-
|
|
3264
|
+
// mutation or state machine actions either (#161). A registered mutation replaces a generated root of
|
|
3265
|
+
// the same name; each replaced root is added to `shadowedRoots` as `{ entry, typeName }`.
|
|
3266
|
+
const buildMutation = (name, includedMutationTypes, includedCustomMutations, shadowedRoots = []) => {
|
|
3064
3267
|
const rootQueryArgs = {};
|
|
3065
3268
|
rootQueryArgs.name = name;
|
|
3066
3269
|
rootQueryArgs.fields = {};
|
|
@@ -3194,6 +3397,8 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
3194
3397
|
|
|
3195
3398
|
for (const [entry, registeredMutation] of Object.entries(registeredMutations)) {
|
|
3196
3399
|
if (!shouldNotBeIncludedInSchema(includedCustomMutations, entry)) {
|
|
3400
|
+
const generated = Object.hasOwn(rootQueryArgs.fields, entry) && rootQueryArgs.fields[entry].extensions?.simfinityMutation;
|
|
3401
|
+
if (generated?.typeName) shadowedRoots.push({ entry, typeName: generated.typeName });
|
|
3197
3402
|
rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredMutation,
|
|
3198
3403
|
operations.CUSTOM_MUTATION);
|
|
3199
3404
|
}
|
|
@@ -3896,11 +4101,46 @@ const invalidSchemaOptions = (detail) => new SimfinityError(
|
|
|
3896
4101
|
`createSchema options ${detail}`, 'INVALID_SCHEMA_OPTIONS', 500,
|
|
3897
4102
|
);
|
|
3898
4103
|
|
|
3899
|
-
const SCHEMA_OPTION_KEYS = new Set(['includedCustomQueries']);
|
|
4104
|
+
const SCHEMA_OPTION_KEYS = new Set(['includedCustomQueries', 'transaction']);
|
|
4105
|
+
|
|
4106
|
+
// The root names an included endpoint type's generated mutations can have, whether or not its inputs
|
|
4107
|
+
// leave some of them out.
|
|
4108
|
+
const generatedMutationNames = ({ simpleEntityEndpointName: endpoint, stateMachine }) => [
|
|
4109
|
+
`add${endpoint}`, `delete${endpoint}`, `update${endpoint}`,
|
|
4110
|
+
...Object.keys(stateMachine?.actions ?? {}).map((actionName) => `${actionName}_${endpoint}`),
|
|
4111
|
+
];
|
|
4112
|
+
|
|
4113
|
+
// The generated transaction field (#151), checked before anything is prepared: the option's shape, its
|
|
4114
|
+
// types (registered in this runtime), the adapter's support, the field name against the included
|
|
4115
|
+
// registered mutations and every root the included endpoint types can generate, and whether any of
|
|
4116
|
+
// its types is an included endpoint type, which always generates a delete root. Returns null or
|
|
4117
|
+
// `{ name, types }`.
|
|
4118
|
+
const readTransactionConfig = (option, includedMutationTypes, includedCustomMutations) => {
|
|
4119
|
+
const config = readTransactionOption(option);
|
|
4120
|
+
if (!config) return null;
|
|
4121
|
+
for (const type of config.types) {
|
|
4122
|
+
assertNotBoundElsewhere(type);
|
|
4123
|
+
if (typesDict.types[type.name]?.gqltype !== type) {
|
|
4124
|
+
throw invalidTransactionOption(`${type.name} is not registered in this runtime`);
|
|
4125
|
+
}
|
|
4126
|
+
}
|
|
4127
|
+
if (typeof adapter.assertTransactionModels !== 'function') throw transactionUnsupportedError();
|
|
4128
|
+
const { name, types } = config;
|
|
4129
|
+
if (Object.hasOwn(registeredMutations, name) && !shouldNotBeIncludedInSchema(includedCustomMutations, name)) {
|
|
4130
|
+
throw invalidTransactionOption(`name ${name} conflicts with the registered mutation ${name}`);
|
|
4131
|
+
}
|
|
4132
|
+
const included = Object.values(typesDict.types)
|
|
4133
|
+
.filter((type) => type.endpoint && !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype));
|
|
4134
|
+
if (included.some((type) => generatedMutationNames(type).includes(name))) {
|
|
4135
|
+
throw invalidTransactionOption(`name ${name} conflicts with a generated mutation`);
|
|
4136
|
+
}
|
|
4137
|
+
if (!included.some((type) => types.includes(type.gqltype))) throw noOperationError();
|
|
4138
|
+
return config;
|
|
4139
|
+
};
|
|
3900
4140
|
|
|
3901
4141
|
// The fourth createSchema() argument: omitted, or a plain object with known keys only.
|
|
3902
|
-
const readSchemaOptions = (options) => {
|
|
3903
|
-
if (options === undefined) return {};
|
|
4142
|
+
const readSchemaOptions = (options, includedMutationTypes, includedCustomMutations) => {
|
|
4143
|
+
if (options === undefined) return { transaction: null };
|
|
3904
4144
|
if (options === null || typeof options !== 'object' || !isPlainObject(options)) {
|
|
3905
4145
|
throw invalidSchemaOptions('must be a plain object');
|
|
3906
4146
|
}
|
|
@@ -3913,13 +4153,410 @@ const readSchemaOptions = (options) => {
|
|
|
3913
4153
|
|| includedCustomQueries.some((name) => typeof name !== 'string'))) {
|
|
3914
4154
|
throw invalidSchemaOptions('includedCustomQueries must be an array of strings, null or undefined');
|
|
3915
4155
|
}
|
|
3916
|
-
|
|
4156
|
+
const transaction = readTransactionConfig(options.transaction, includedMutationTypes, includedCustomMutations);
|
|
4157
|
+
return { includedCustomQueries, transaction };
|
|
4158
|
+
};
|
|
4159
|
+
|
|
4160
|
+
// The types whose collections a transaction's references count (planReferences, item 4): every type
|
|
4161
|
+
// registered in this runtime.
|
|
4162
|
+
const referenceRegistry = { registeredTypes: () => Object.values(typesDict.types).map(({ gqltype }) => gqltype) };
|
|
4163
|
+
|
|
4164
|
+
// The model affinity check (#151), before the session opens, so that models that cannot share one database
|
|
4165
|
+
// transaction fail before anything runs. The adapter's check is synchronous: a thenable it returns is
|
|
4166
|
+
// refused, once the rejection of a native promise is handled so it cannot crash the process. An error it
|
|
4167
|
+
// throws passes through unchanged.
|
|
4168
|
+
const assertTransactionModels = (models) => {
|
|
4169
|
+
if (typeof adapter.assertTransactionModels !== 'function') throw transactionUnsupportedError();
|
|
4170
|
+
const result = adapter.assertTransactionModels(models);
|
|
4171
|
+
if (isThenable(result)) {
|
|
4172
|
+
discardThenable(result);
|
|
4173
|
+
throw new SimfinityError('assertTransactionModels must be synchronous', 'TRANSACTION_UNSUPPORTED', 500);
|
|
4174
|
+
}
|
|
4175
|
+
};
|
|
4176
|
+
|
|
4177
|
+
// An error of the input walk of one operation, as a new SimfinityError with the same message, code and
|
|
4178
|
+
// status that names the operation, as planReferences reports its errors. Any other error is not the
|
|
4179
|
+
// input's and passes through.
|
|
4180
|
+
const operationInputError = (error, index, key) => {
|
|
4181
|
+
if (!(error instanceof SimfinityError)) return error;
|
|
4182
|
+
const positioned = new SimfinityError(error.message, error.extensions.code, error.extensions.status);
|
|
4183
|
+
positioned.extensions.operationIndex = index;
|
|
4184
|
+
positioned.extensions.operation = key;
|
|
4185
|
+
return positioned;
|
|
4186
|
+
};
|
|
4187
|
+
|
|
4188
|
+
// The steps of the list GraphQL coerced, `{ index, key, entry }`, one per item. GraphQL has checked the
|
|
4189
|
+
// items (@oneOf), so each has exactly one of the field's operation keys.
|
|
4190
|
+
const transactionSteps = (transactionState, operationList) => operationList.map((item, index) => {
|
|
4191
|
+
const keys = item !== null && typeof item === 'object' ? Object.keys(item) : [];
|
|
4192
|
+
const entry = keys.length === 1 ? transactionState.operations.get(keys[0]) : undefined;
|
|
4193
|
+
if (entry === undefined) throw new TypeError(`Item ${index} is not an operation of ${transactionState.name}`);
|
|
4194
|
+
return { index, key: keys[0], entry };
|
|
4195
|
+
});
|
|
4196
|
+
|
|
4197
|
+
// The input walk over the list GraphQL coerced, which applies the scalar and record-key rules, bounds the
|
|
4198
|
+
// nesting and requires the top-level id of an update, an action or a delete. Returns the snapshot: one
|
|
4199
|
+
// `{ [key]: value }` per item, which the list never shares.
|
|
4200
|
+
const snapshotTransaction = (steps, operationList) => steps.map(({ index, key, entry }) => {
|
|
4201
|
+
try {
|
|
4202
|
+
return {
|
|
4203
|
+
[key]: snapshotOperationInput(operationList[index][key], entry.valueType, {
|
|
4204
|
+
...operationWalkOptions, mode: 'coerced', path: `operations[${index}].${key}`, topLevelId: entry.operation !== operations.SAVE,
|
|
4205
|
+
}),
|
|
4206
|
+
};
|
|
4207
|
+
} catch (error) {
|
|
4208
|
+
throw operationInputError(error, index, key);
|
|
4209
|
+
}
|
|
4210
|
+
});
|
|
4211
|
+
|
|
4212
|
+
// The nested entries of the whole list, nulls included, against the snapshotted limit; null is no limit.
|
|
4213
|
+
// `list` is the list GraphQL coerced, or a snapshot of it, which has the same entries. It only looks
|
|
4214
|
+
// ahead: the attempts count what runs. A delete has no nested entries.
|
|
4215
|
+
const assertTransactionNestedOperations = (steps, list, limit) => {
|
|
4216
|
+
if (limit === null) return;
|
|
4217
|
+
const counted = { count: 0 };
|
|
4218
|
+
for (const { index, key, entry } of steps) {
|
|
4219
|
+
if (entry.operation !== operations.DELETE) assertNestedOperations(entry.type.gqltype, list[index][key], limit, counted);
|
|
4220
|
+
}
|
|
4221
|
+
};
|
|
4222
|
+
|
|
4223
|
+
// What identifies the marker of a generated root by content: its type, operation and action, which a copy
|
|
4224
|
+
// of the marker (a spread or a deep clone) keeps. Anything else under the key identifies nothing.
|
|
4225
|
+
const rootMarkerIdentity = (marker) => {
|
|
4226
|
+
if (marker === null || typeof marker !== 'object') return undefined;
|
|
4227
|
+
const { typeName, operation, action } = marker;
|
|
4228
|
+
if (typeof typeName !== 'string' || typeof operation !== 'string'
|
|
4229
|
+
|| (action !== undefined && action !== null && typeof action !== 'string')) return undefined;
|
|
4230
|
+
return JSON.stringify([typeName, operation, action ?? null]);
|
|
4231
|
+
};
|
|
4232
|
+
|
|
4233
|
+
// An operation whose root the Mutation type that runs the transaction does not expose, positioned as the
|
|
4234
|
+
// input walk and the references report their errors.
|
|
4235
|
+
const unexposedOperationError = (mutationType, operationIndex, operation) => {
|
|
4236
|
+
const error = new ForbiddenError(`Access denied to ${mutationType.name}.${operation}`);
|
|
4237
|
+
error.extensions.operationIndex = operationIndex;
|
|
4238
|
+
error.extensions.operation = operation;
|
|
4239
|
+
return error;
|
|
4240
|
+
};
|
|
4241
|
+
|
|
4242
|
+
// Where each operation of the list may run: `mutationType`, the Mutation type that runs the
|
|
4243
|
+
// transaction (`info.parentType`), must expose its generated root, that is, have a field whose marker is
|
|
4244
|
+
// the marker of the root the field was built with, or a copy of it, under any name. A served copy of the
|
|
4245
|
+
// schema can drop a root, or put another field under its name, and a direct call could then not reach the
|
|
4246
|
+
// root there, so neither may an operation: the first operation of the list whose root is not exposed
|
|
4247
|
+
// fails with FORBIDDEN, `Access denied to <Mutation type>.<key>`, before the transaction.
|
|
4248
|
+
//
|
|
4249
|
+
// Returns operation key -> the roots whose checks the operation gets on every attempt: one per distinct
|
|
4250
|
+
// marker, by identity, among the markers of the fields that expose the root and the marker of the root the
|
|
4251
|
+
// field was built with. The identity field's marker comes first, then those of the other fields in field
|
|
4252
|
+
// order, then the built one. A copy that renames the root, aliases it or clones its markers thus runs the
|
|
4253
|
+
// checks of every name that reaches the root, and never fewer than the schema the field was built in: a
|
|
4254
|
+
// transform that clones the markers would otherwise drop the entries its copied wrappers still enforce.
|
|
4255
|
+
// authorizeOperation reads only a root's name, type and marker, so each root is a view of one marker with
|
|
4256
|
+
// the identity the rules see: the operation key as `info.fieldName`, and the type of the identity field,
|
|
4257
|
+
// which is the field that exposes the root under the key, or else the first field that exposes it, in field
|
|
4258
|
+
// order. The rules' `info.parentType` is the Mutation type that runs the transaction, whose fields they all
|
|
4259
|
+
// are.
|
|
4260
|
+
const exposedOperationRoots = (steps, mutationType) => {
|
|
4261
|
+
const exposing = new Map();
|
|
4262
|
+
for (const field of Object.values(mutationType.getFields())) {
|
|
4263
|
+
const identity = rootMarkerIdentity(field.extensions?.simfinityMutation);
|
|
4264
|
+
if (identity === undefined) continue;
|
|
4265
|
+
if (!exposing.has(identity)) exposing.set(identity, []);
|
|
4266
|
+
exposing.get(identity).push(field);
|
|
4267
|
+
}
|
|
4268
|
+
const roots = new Map();
|
|
4269
|
+
for (const { index, key, entry } of steps) {
|
|
4270
|
+
if (roots.has(key)) continue;
|
|
4271
|
+
const builtMarker = entry.rootField.extensions.simfinityMutation;
|
|
4272
|
+
const fields = exposing.get(rootMarkerIdentity(builtMarker)) ?? [];
|
|
4273
|
+
if (fields.length === 0) throw unexposedOperationError(mutationType, index, key);
|
|
4274
|
+
const identityField = fields.find((field) => field.name === key) ?? fields[0];
|
|
4275
|
+
const markers = new Set([identityField, ...fields].map((field) => field.extensions.simfinityMutation));
|
|
4276
|
+
markers.add(builtMarker);
|
|
4277
|
+
roots.set(key, [...markers].map((marker) => Object.freeze({
|
|
4278
|
+
name: key, type: identityField.type, extensions: Object.freeze({ simfinityMutation: marker }),
|
|
4279
|
+
})));
|
|
4280
|
+
}
|
|
4281
|
+
return roots;
|
|
4282
|
+
};
|
|
4283
|
+
|
|
4284
|
+
// The checks a direct call to an operation's root gets: the entries the auth plugin recorded
|
|
4285
|
+
// on each of the operation's roots (exposedOperationRoots), in order.
|
|
4286
|
+
const authorizeTransactionOperation = async (roots, parent, args, context, info) => {
|
|
4287
|
+
for (const root of roots) await authorizeOperation(root, parent, args, context, info);
|
|
4288
|
+
};
|
|
4289
|
+
|
|
4290
|
+
// Whether the error that ended the transaction is the one that ended the final attempt, `operationError`,
|
|
4291
|
+
// or an error an adapter made of it, such as TRANSACTION_RETRY_EXCEEDED, that keeps it in its `cause`
|
|
4292
|
+
// chain. Any other error, such as the failure of a begin, a commit, a rollback or a release, or one of an
|
|
4293
|
+
// earlier attempt, is not that operation's.
|
|
4294
|
+
const reachesOperationError = (error, operationError) => {
|
|
4295
|
+
if (error === operationError) return true;
|
|
4296
|
+
const seen = new Set();
|
|
4297
|
+
let current = error;
|
|
4298
|
+
while ((typeof current === 'object' && current !== null) || typeof current === 'function') {
|
|
4299
|
+
if (seen.has(current)) return false;
|
|
4300
|
+
seen.add(current);
|
|
4301
|
+
current = current.cause;
|
|
4302
|
+
if (current != null && current === operationError) return true;
|
|
4303
|
+
}
|
|
4304
|
+
return false;
|
|
4305
|
+
};
|
|
4306
|
+
|
|
4307
|
+
// The record of each result item of a transaction that has one, with the item's operation key and the type
|
|
4308
|
+
// that operation's root returned when the field was built: what TransactionResult.id resolves from. The
|
|
4309
|
+
// item of a delete of a missing record has none.
|
|
4310
|
+
const transactionResultRecords = new WeakMap();
|
|
4311
|
+
|
|
4312
|
+
// The record's type in the executing schema, whose fields a plugin applied to that schema wrapped: the
|
|
4313
|
+
// object type the executing result type gives the operation key, or else the executing schema's object
|
|
4314
|
+
// type of the name the root returned when the field was built. Undefined when it has neither: the type the
|
|
4315
|
+
// field was built with is never used, since a plugin applied to a copy with new type objects never wrapped it.
|
|
4316
|
+
const executingRecordType = (info, key, builtType) => {
|
|
4317
|
+
const resultFields = info.parentType.getFields();
|
|
4318
|
+
const executing = Object.hasOwn(resultFields, key) ? getNamedType(resultFields[key].type) : undefined;
|
|
4319
|
+
if (isObjectType(executing)) return executing;
|
|
4320
|
+
const named = info.schema.getType(builtType.name);
|
|
4321
|
+
return isObjectType(named) ? named : undefined;
|
|
4322
|
+
};
|
|
4323
|
+
|
|
4324
|
+
// TransactionResult.id: the id of the operation's record, resolved by the `id` field of the record's own
|
|
4325
|
+
// type in the executing schema (executingRecordType), with the record as parent, no arguments and that
|
|
4326
|
+
// field's info, as a direct call to the operation's root that selects `id` resolves it, so that a rule an
|
|
4327
|
+
// auth plugin put on `<Type>.id` applies here too. Without such a type the id is refused with FORBIDDEN
|
|
4328
|
+
// (null and the error). An item without a record, and a type without an `id` field, give null without
|
|
4329
|
+
// calling anything.
|
|
4330
|
+
const resolveTransactionResultId = (item, args, context, info) => {
|
|
4331
|
+
const result = item !== null && typeof item === 'object' ? transactionResultRecords.get(item) : undefined;
|
|
4332
|
+
if (result === undefined) return null;
|
|
4333
|
+
const { key, record, builtType } = result;
|
|
4334
|
+
const recordType = executingRecordType(info, key, builtType);
|
|
4335
|
+
if (recordType === undefined) throw new ForbiddenError(`Access denied to ${info.parentType.name}.id`);
|
|
4336
|
+
const recordFields = recordType.getFields();
|
|
4337
|
+
if (!Object.hasOwn(recordFields, 'id')) return null;
|
|
4338
|
+
const idField = recordFields.id;
|
|
4339
|
+
return (idField.resolve ?? defaultFieldResolver)(record, {}, context, {
|
|
4340
|
+
...info, fieldName: 'id', parentType: recordType, returnType: idField.type,
|
|
4341
|
+
});
|
|
4342
|
+
};
|
|
4343
|
+
|
|
4344
|
+
// Runs a non-empty operation list of a transaction field and returns one result per operation, `{ id,
|
|
4345
|
+
// [key]: record }`, where the record of a delete of a missing record and its id are null. Before the
|
|
4346
|
+
// transaction: the limits are read once, then come the operation count, the model affinity check, the
|
|
4347
|
+
// nested entries of the whole list, counted on what GraphQL coerced so that a list past the limit fails as
|
|
4348
|
+
// a root past it does, the input walk, the references and the roots the Mutation type that runs the
|
|
4349
|
+
// transaction exposes. Then one owned transaction runs the list, retries included; each
|
|
4350
|
+
// attempt works on its own copy of the snapshot, with an execution state on its session for the limits and
|
|
4351
|
+
// the joint nested budget. When the error of an operation of the final attempt ends the transaction, the
|
|
4352
|
+
// resolver reports it with that operation's position (decorateOperationError); every other error is
|
|
4353
|
+
// reported as it is.
|
|
4354
|
+
const executeTransaction = async (transactionState, operationList, parent, context, info) => {
|
|
4355
|
+
const limits = Object.freeze({
|
|
4356
|
+
maxNestedOperations: getMaxNestedOperations(), maxTransactionOperations: getMaxTransactionOperations(),
|
|
4357
|
+
});
|
|
4358
|
+
if (operationList.length > limits.maxTransactionOperations) {
|
|
4359
|
+
throw transactionOperationsExceededError(limits.maxTransactionOperations);
|
|
4360
|
+
}
|
|
4361
|
+
assertTransactionModels(transactionState.models);
|
|
4362
|
+
const steps = transactionSteps(transactionState, operationList);
|
|
4363
|
+
assertTransactionNestedOperations(steps, operationList, limits.maxNestedOperations);
|
|
4364
|
+
const snapshot = snapshotTransaction(steps, operationList);
|
|
4365
|
+
// Once, on the snapshot, which it changes in place: `$$` literals lose a `$` before any attempt copies them.
|
|
4366
|
+
const referencePlan = planReferences(snapshot, transactionState, referenceRegistry);
|
|
4367
|
+
// The plan's positions by operation index, in plan order, so that each operation reads only its own.
|
|
4368
|
+
const referencesOf = new Map();
|
|
4369
|
+
for (const position of referencePlan) {
|
|
4370
|
+
if (!referencesOf.has(position.index)) referencesOf.set(position.index, []);
|
|
4371
|
+
referencesOf.get(position.index).push(position);
|
|
4372
|
+
}
|
|
4373
|
+
const operationRoots = exposedOperationRoots(steps, info.parentType);
|
|
4374
|
+
|
|
4375
|
+
// One record per attempt that started, in order. Its `failure` is `{ error, operationIndex, operation }`
|
|
4376
|
+
// for the operation whose error ended the attempt, or null: errors propagate unchanged while attempts
|
|
4377
|
+
// run, so that the adapter can still recognize them, and the position of the final attempt's failure
|
|
4378
|
+
// stays here for the error the resolver reports.
|
|
4379
|
+
const attempts = [];
|
|
4380
|
+
const runAttempt = async (session) => {
|
|
4381
|
+
// The joint budget follows the attempt's session; one that cannot carry it would leave it unchecked.
|
|
4382
|
+
if (session === null || (typeof session !== 'object' && typeof session !== 'function')) {
|
|
4383
|
+
throw transactionUnsupportedError();
|
|
4384
|
+
}
|
|
4385
|
+
const attempt = { failure: null };
|
|
4386
|
+
attempts.push(attempt);
|
|
4387
|
+
const state = createExecutionState({ runtimeToken, limits });
|
|
4388
|
+
attachExecutionState(session, state);
|
|
4389
|
+
try {
|
|
4390
|
+
const copies = steps.map(({ index, key, entry }) => ({
|
|
4391
|
+
[key]: snapshotOperationInput(snapshot[index][key], entry.valueType, { ...operationWalkOptions, mode: 'copy' }),
|
|
4392
|
+
}));
|
|
4393
|
+
// `{ id }` of each operation's record in this attempt, or null, which resolveReferences reads.
|
|
4394
|
+
const written = [];
|
|
4395
|
+
// One operation: its references, the checks of its roots with the root's args,
|
|
4396
|
+
// `{ input }` or, for a delete, `{ id }` or `{ id, version }`, the root's middleware with the same
|
|
4397
|
+
// args, then, on a copy of what middleware left and before anything of it is written, the look-ahead
|
|
4398
|
+
// of its nested entries and the collection check, and the operation itself on the attempt's session.
|
|
4399
|
+
const runStep = async ({ index, key, entry }) => {
|
|
4400
|
+
const references = referencesOf.get(index);
|
|
4401
|
+
if (references !== undefined) resolveReferences(copies, references, written, index);
|
|
4402
|
+
const args = entry.operation === operations.DELETE ? copies[index][key] : { input: copies[index][key] };
|
|
4403
|
+
await authorizeTransactionOperation(operationRoots.get(key), parent, args, context, info);
|
|
4404
|
+
await executeMiddleware(entry.operation === operations.STATE_CHANGED
|
|
4405
|
+
? {
|
|
4406
|
+
type: entry.type, args, operation: entry.operation, actionName: entry.actionName, actionField: entry.actionField, context,
|
|
4407
|
+
}
|
|
4408
|
+
: {
|
|
4409
|
+
type: entry.type, args, operation: entry.operation, context,
|
|
4410
|
+
});
|
|
4411
|
+
const { model: Model, gqltype, controller } = entry.type;
|
|
4412
|
+
if (entry.operation === operations.DELETE) {
|
|
4413
|
+
// As the delete root: a protected delete takes the version as middleware left it.
|
|
4414
|
+
const target = entry.protected ? { id: args.id, version: args.version } : args.id;
|
|
4415
|
+
return runOperation({ Model, gqltype, controller }, entry.operation, target, session, context);
|
|
4416
|
+
}
|
|
4417
|
+
// The operation gets its own copy of the input middleware left, as each attempt of a root does, so
|
|
4418
|
+
// a value middleware put there, which may be shared, is never changed by it and a retry starts from it.
|
|
4419
|
+
const input = cloneInput(args.input, entry.valueType);
|
|
4420
|
+
// As the roots, the nested limit comes before the pre-read, so it wins over an early VERSION_CONFLICT.
|
|
4421
|
+
assertRemainingNestedBudget(gqltype, input, state);
|
|
4422
|
+
assertCollectionWrites(gqltype, input);
|
|
4423
|
+
return runOperation({ Model, gqltype, controller }, entry.operation, input, session, context,
|
|
4424
|
+
entry.actionField ?? null, entry.actionName);
|
|
4425
|
+
};
|
|
4426
|
+
const results = [];
|
|
4427
|
+
for (const step of steps) {
|
|
4428
|
+
let record;
|
|
4429
|
+
try {
|
|
4430
|
+
record = await runStep(step);
|
|
4431
|
+
} catch (error) {
|
|
4432
|
+
attempt.failure = { error, operationIndex: step.index, operation: step.key };
|
|
4433
|
+
throw error;
|
|
4434
|
+
}
|
|
4435
|
+
const id = record == null ? null : record._id ?? record.id ?? null;
|
|
4436
|
+
written[step.index] = record == null ? null : { id };
|
|
4437
|
+
const item = { id, [step.key]: record ?? null };
|
|
4438
|
+
if (record != null) {
|
|
4439
|
+
transactionResultRecords.set(item, {
|
|
4440
|
+
key: step.key, record, builtType: getNamedType(step.entry.rootField.type),
|
|
4441
|
+
});
|
|
4442
|
+
}
|
|
4443
|
+
results.push(item);
|
|
4444
|
+
}
|
|
4445
|
+
return results;
|
|
4446
|
+
} finally {
|
|
4447
|
+
detachExecutionState(session, state);
|
|
4448
|
+
}
|
|
4449
|
+
};
|
|
4450
|
+
try {
|
|
4451
|
+
return await adapter.withTransaction(null, runAttempt, steps[0].entry.type.model);
|
|
4452
|
+
} catch (error) {
|
|
4453
|
+
const { failure } = attempts.at(-1) ?? {};
|
|
4454
|
+
throw failure && reachesOperationError(error, failure.error)
|
|
4455
|
+
? decorateOperationError(error, failure.operationIndex, failure.operation) : error;
|
|
4456
|
+
}
|
|
4457
|
+
};
|
|
4458
|
+
|
|
4459
|
+
// The models one transaction of the field may write (#151): those of the types that own
|
|
4460
|
+
// its operations, then those of the referenced collections their inputs declare, through the items'
|
|
4461
|
+
// own inputs and embedded values at any depth, without null models or repeats.
|
|
4462
|
+
const transactionModels = (operationEntries) => {
|
|
4463
|
+
const models = [];
|
|
4464
|
+
const add = (model) => {
|
|
4465
|
+
if (model != null && !models.includes(model)) models.push(model);
|
|
4466
|
+
};
|
|
4467
|
+
const owners = [...new Set([...operationEntries.values()].map(({ type }) => typesDict.types[type.gqltype.name]))];
|
|
4468
|
+
owners.forEach(({ model }) => add(model));
|
|
4469
|
+
const visited = new Set();
|
|
4470
|
+
const visit = (gqltype) => {
|
|
4471
|
+
if (visited.has(gqltype)) return;
|
|
4472
|
+
visited.add(gqltype);
|
|
4473
|
+
for (const field of Object.values(gqltype.getFields())) {
|
|
4474
|
+
if (isReadOnlyField(field)) continue;
|
|
4475
|
+
const listShape = getListShape(field.type);
|
|
4476
|
+
const target = listShape ? listShape.itemType : unwrapNonNull(field.type);
|
|
4477
|
+
const registration = target instanceof GraphQLObjectType ? typesDict.types[target.name] : undefined;
|
|
4478
|
+
if (registration?.gqltype !== target) continue;
|
|
4479
|
+
const embedded = !!field.extensions?.relation?.embedded;
|
|
4480
|
+
if (listShape && !embedded) {
|
|
4481
|
+
add(registration.model);
|
|
4482
|
+
visit(target);
|
|
4483
|
+
} else if (embedded) {
|
|
4484
|
+
visit(target);
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4487
|
+
};
|
|
4488
|
+
owners.forEach(({ gqltype }) => visit(gqltype));
|
|
4489
|
+
return models;
|
|
4490
|
+
};
|
|
4491
|
+
|
|
4492
|
+
// The transaction field of a schema whose generated mutations are built, from the roots of `mutation`,
|
|
4493
|
+
// which does not have it yet, and `query`, the final query root. Returns `{ field, state }`, where the
|
|
4494
|
+
// state is what executeTransaction reads: `name`, `typeNames`, `operations` (operation key ->
|
|
4495
|
+
// `{ type, operation, actionName?, actionField?, protected, rootField, valueType }`, with the
|
|
4496
|
+
// registration the root runs: the update one for updates and actions; the root field the schema was
|
|
4497
|
+
// built with, whose authorization entries a copy of the schema cannot drop; and the type of the
|
|
4498
|
+
// operation's value in the field's operation type) and `models`, frozen, which the adapter's affinity
|
|
4499
|
+
// check receives.
|
|
4500
|
+
const buildTransaction = ({ name, types }, query, mutation, shadowedRoots) => {
|
|
4501
|
+
const typeNames = types.map((type) => type.name);
|
|
4502
|
+
for (const { entry, typeName } of shadowedRoots) {
|
|
4503
|
+
if (typeNames.includes(typeName)) {
|
|
4504
|
+
warnOnce(typesDict.types[typeName].gqltype, `transactionShadowed:${name}:${entry}`, 'Configuration issue: the '
|
|
4505
|
+
+ `registered mutation ${entry} replaces the generated mutation of ${typeName} with that name, so the ${name} `
|
|
4506
|
+
+ `field has no ${entry} operation.`);
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
const operationEntries = new Map();
|
|
4510
|
+
const descriptors = [];
|
|
4511
|
+
for (const { key, field, marker } of collectOperationFields(mutation, typeNames)) {
|
|
4512
|
+
const forUpdate = marker.operation === operations.UPDATE || marker.operation === operations.STATE_CHANGED;
|
|
4513
|
+
const type = (forUpdate ? typesDictForUpdate : typesDict).types[marker.typeName];
|
|
4514
|
+
const entry = {
|
|
4515
|
+
type, operation: marker.operation, protected: isProtected(type.gqltype), rootField: field,
|
|
4516
|
+
};
|
|
4517
|
+
if (marker.operation === operations.STATE_CHANGED) {
|
|
4518
|
+
entry.actionName = marker.action;
|
|
4519
|
+
entry.actionField = type.stateMachine.actions[marker.action];
|
|
4520
|
+
}
|
|
4521
|
+
operationEntries.set(key, entry);
|
|
4522
|
+
const input = field.args.find((arg) => arg.name === 'input');
|
|
4523
|
+
descriptors.push({
|
|
4524
|
+
key,
|
|
4525
|
+
operation: marker.operation,
|
|
4526
|
+
protected: entry.protected,
|
|
4527
|
+
inputType: input ? unwrapNonNull(input.type) : null,
|
|
4528
|
+
returnType: field.type,
|
|
4529
|
+
});
|
|
4530
|
+
}
|
|
4531
|
+
assertTransactionSchema({
|
|
4532
|
+
name, mutation, operations: descriptors, typeExists: (typeName) => findSchemaType([query, mutation], typeName) !== null,
|
|
4533
|
+
});
|
|
4534
|
+
const state = {
|
|
4535
|
+
name, typeNames, operations: operationEntries, models: Object.freeze(transactionModels(operationEntries)),
|
|
4536
|
+
};
|
|
4537
|
+
const field = buildTransactionField({
|
|
4538
|
+
name,
|
|
4539
|
+
types,
|
|
4540
|
+
operations: descriptors,
|
|
4541
|
+
recordVersionRef: RecordVersionRef,
|
|
4542
|
+
resolveId: resolveTransactionResultId,
|
|
4543
|
+
// An empty list runs nothing: no transaction and no affinity check.
|
|
4544
|
+
async resolve(parent, args, context, info) {
|
|
4545
|
+
const { operations: list } = args;
|
|
4546
|
+
if (list.length === 0) return [];
|
|
4547
|
+
return executeTransaction(state, list, parent, context, info);
|
|
4548
|
+
},
|
|
4549
|
+
});
|
|
4550
|
+
const valueTypes = getNamedType(field.args.operations.type).getFields();
|
|
4551
|
+
for (const [key, entry] of operationEntries) entry.valueType = valueTypes[key].type;
|
|
4552
|
+
return { field, state };
|
|
3917
4553
|
};
|
|
3918
4554
|
|
|
3919
4555
|
const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations, options) => {
|
|
3920
4556
|
Object.values(typesDict.types).forEach(({ controller }) => afterCommit.validate(controller));
|
|
3921
4557
|
// Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
|
|
3922
|
-
const { includedCustomQueries } = readSchemaOptions(options
|
|
4558
|
+
const { includedCustomQueries, transaction } = readSchemaOptions(options, includedMutationTypes,
|
|
4559
|
+
includedCustomMutations);
|
|
3923
4560
|
assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
|
|
3924
4561
|
assertViewsAvailable(includedQueryTypes, includedCustomQueries);
|
|
3925
4562
|
Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
|
|
@@ -3995,7 +4632,8 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3995
4632
|
applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
|
|
3996
4633
|
}
|
|
3997
4634
|
});
|
|
3998
|
-
const
|
|
4635
|
+
const shadowedRoots = [];
|
|
4636
|
+
let mutation = buildMutation('Mutation', includedMutationTypes, includedCustomMutations, shadowedRoots);
|
|
3999
4637
|
// An application scalar named JSON that this schema contains also types aggregation results. Any
|
|
4000
4638
|
// other type named JSON still collides, and GraphQLSchema reports the duplicate name.
|
|
4001
4639
|
const applicationJSON = findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult);
|
|
@@ -4003,8 +4641,12 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
4003
4641
|
query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON),
|
|
4004
4642
|
includedCustomQueries);
|
|
4005
4643
|
}
|
|
4644
|
+
// The transaction field's checks that need the built roots run on them before it is added (#151).
|
|
4645
|
+
const built = transaction && buildTransaction(transaction, query, mutation, shadowedRoots);
|
|
4646
|
+
if (built) mutation = withTransactionField(mutation, transaction.name, built.field);
|
|
4006
4647
|
|
|
4007
4648
|
const schema = constructSchema(query, mutation, reserveVersionNames);
|
|
4649
|
+
if (built) recordTransactionState(built.field, built.state);
|
|
4008
4650
|
captureSchemaRegistrations(schema, Object.values(typesDict.types).map((entry) => ({
|
|
4009
4651
|
gqltype: entry.gqltype, endpoint: entry.endpoint,
|
|
4010
4652
|
// Only associate roots generated for this schema. Excluded endpoint names can be custom queries.
|
|
@@ -4013,7 +4655,8 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
4013
4655
|
inputType: entry.inputType, updateInputType: typesDictForUpdate.types[entry.gqltype.name]?.inputType,
|
|
4014
4656
|
stateMachine: entry.stateMachine,
|
|
4015
4657
|
})), (type, field) => field.extensions?.queryable !== false
|
|
4016
|
-
&& (field.extensions?.queryable === true || !hasApplicationResolver(type, field.name))
|
|
4658
|
+
&& (field.extensions?.queryable === true || !hasApplicationResolver(type, field.name)),
|
|
4659
|
+
built ? schema.getMutationType().getFields()[transaction.name] : undefined);
|
|
4017
4660
|
// Reserve reachable types whose relation fields still have no resolver, such as an unregistered
|
|
4018
4661
|
// custom operation result, so another runtime cannot later generate resolvers this schema executes.
|
|
4019
4662
|
schemaTypes.forEach((type) => {
|
|
@@ -4382,6 +5025,9 @@ function formatArgs(argsArray) {
|
|
|
4382
5025
|
registerCustomQuery,
|
|
4383
5026
|
registerView,
|
|
4384
5027
|
saveObject,
|
|
5028
|
+
updateObject,
|
|
5029
|
+
deleteObject,
|
|
5030
|
+
transitionObject,
|
|
4385
5031
|
preventCreatingCollection,
|
|
4386
5032
|
};
|
|
4387
5033
|
};
|