@simtlix/simfinity-core 3.14.0 → 3.16.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 +31 -4
- package/package.json +12 -2
- package/src/after-commit.js +83 -0
- 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/introspection-config.js +17 -0
- package/src/introspection-state.js +10 -0
- package/src/introspection.js +5 -3
- package/src/mutation-limits.js +37 -7
- package/src/operation-input.js +345 -0
- package/src/query-frames.js +42 -7
- package/src/query-operators.js +90 -6
- package/src/query-plan.js +15 -4
- package/src/runtime.js +722 -55
- package/src/schema-registrations.js +23 -0
- package/src/transaction-callbacks.js +34 -0
- package/src/transaction-references.js +259 -0
- package/src/transaction-schema.js +163 -0
- package/types/index.d.ts +186 -17
- package/types/introspection.d.ts +4 -0
package/src/runtime.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
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';
|
|
8
|
+
import { createAfterCommit } from './after-commit.js';
|
|
5
9
|
import {
|
|
6
10
|
GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
|
|
7
11
|
GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
|
|
8
12
|
GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
|
|
9
|
-
Kind, getNamedType, isEnumType, isListType, isNonNullType, isObjectType, isOutputType,
|
|
13
|
+
Kind, defaultFieldResolver, getNamedType, isEnumType, isListType, isNonNullType, isObjectType, isOutputType,
|
|
14
|
+
isScalarType,
|
|
10
15
|
} from 'graphql';
|
|
11
16
|
|
|
12
17
|
import SimfinityError from './errors/simfinity.error.js';
|
|
18
|
+
import { captureSchemaRegistrations } from './schema-registrations.js';
|
|
13
19
|
import InternalServerError from './errors/internal-server.error.js';
|
|
14
20
|
import { ForbiddenError } from './auth/errors.js';
|
|
15
21
|
import QLOperator from './const/QLOperator.js';
|
|
@@ -25,7 +31,8 @@ import {
|
|
|
25
31
|
assertEmptinessPath, describeEmptiness, emptinessScopeTargets, isEmptinessOperator,
|
|
26
32
|
} from './query-emptiness.js';
|
|
27
33
|
import { relationFieldOwners } from './relation-owners.js';
|
|
28
|
-
import { isThenable } from './auth/thenable.js';
|
|
34
|
+
import { discardThenable, isThenable } from './auth/thenable.js';
|
|
35
|
+
import { authorizeOperation } from './auth/operation-entries.js';
|
|
29
36
|
import {
|
|
30
37
|
assertPlainConnectionField, getFieldStorageName, getListShape, isVirtualField, markStoredIdentity, normalizeConnectionField,
|
|
31
38
|
} from './relation-storage.js';
|
|
@@ -41,6 +48,16 @@ import {
|
|
|
41
48
|
unexpectedDeleteError, versionConflictError, versionConnectionCollision, versionEmbeddedMisuse, versionExhaustedError,
|
|
42
49
|
versionFieldCollision, versionManagedError,
|
|
43
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';
|
|
44
61
|
|
|
45
62
|
// Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
|
|
46
63
|
// created is application code, such as a masking resolver, recorded once per type so later
|
|
@@ -428,14 +445,21 @@ const classifyValue = (value) => {
|
|
|
428
445
|
: new InternalServerError('Unexpected error value');
|
|
429
446
|
};
|
|
430
447
|
|
|
448
|
+
// A value thrown while executing a field. graphql-js raises its own code-less GraphQLErrors there too
|
|
449
|
+
// when it cannot complete a value (scalar or enum serialization, isTypeOf, abstract type resolution, a
|
|
450
|
+
// non-iterable list), and some print the value. So a GraphQLError raised there is an application error
|
|
451
|
+
// only with its own code or a SimfinityError cause; any other is unexpected.
|
|
452
|
+
const classifyThrown = (thrown) => {
|
|
453
|
+
if (thrown instanceof GraphQLError && typeof thrown.extensions?.code !== 'string' && !simfinityCauseOf(thrown)) {
|
|
454
|
+
return new InternalServerError(thrown.message, thrown);
|
|
455
|
+
}
|
|
456
|
+
return classifyValue(thrown);
|
|
457
|
+
};
|
|
458
|
+
|
|
431
459
|
// Errors raised while executing a field have a path; request errors (syntax, validation, variable
|
|
432
460
|
// coercion) never do. GraphQL servers pass the GraphQLError that graphql-js created around the value
|
|
433
461
|
// a resolver threw, and that value is classified, without unwrapping a GraphQLError the resolver
|
|
434
462
|
// threw any further. A thrown GraphQLError that already has a path is passed as is.
|
|
435
|
-
// graphql-js raises its own code-less GraphQLErrors there too when it cannot complete a value (scalar
|
|
436
|
-
// or enum serialization, isTypeOf, abstract type resolution, a non-iterable list), and some print the
|
|
437
|
-
// value. So a GraphQLError raised there is an application error only with its own code or a
|
|
438
|
-
// SimfinityError cause; any other is unexpected.
|
|
439
463
|
const classifyError = (error) => {
|
|
440
464
|
if (!(error instanceof GraphQLError)) return classifyValue(error);
|
|
441
465
|
if (!Array.isArray(error.path)) {
|
|
@@ -445,11 +469,33 @@ const classifyError = (error) => {
|
|
|
445
469
|
const cause = simfinityCauseOf(error);
|
|
446
470
|
return cause instanceof InternalServerError ? cause : classifyValue(error);
|
|
447
471
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
472
|
+
return classifyThrown(isFieldErrorWrapper(error) ? thrownValueOf(error.originalError) : error);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// The error that a transaction field reports when the operation `operation`, at `operationIndex`, ended
|
|
476
|
+
// it (#151): a new error with the code, status and message that buildErrorFormatter gives
|
|
477
|
+
// `cause` when a root resolver throws it, and every extension of it, plus the operation's position. An
|
|
478
|
+
// InternalServerError stays one, as for a root, so the callback that masks unexpected errors still hides
|
|
479
|
+
// the message of a cause that is not an application error. The original is its non-enumerable `cause`,
|
|
480
|
+
// out of every copy of the error, and is never changed: it may be frozen, or shared. `getCause()` of an
|
|
481
|
+
// InternalServerError does not return a value that is not an Error, as for a root. Any other decoration
|
|
482
|
+
// of a cause that has a `getCause()`, such as the DATABASE_ERROR and TRANSACTION_RETRY_EXCEEDED errors the
|
|
483
|
+
// adapters build, keeps it: it returns what the cause's own returns, the driver error.
|
|
484
|
+
const decorateOperationError = (cause, operationIndex, operation) => {
|
|
485
|
+
const classified = classifyThrown(cause);
|
|
486
|
+
const decorated = classified instanceof InternalServerError
|
|
487
|
+
? new InternalServerError(classified.message)
|
|
488
|
+
: new SimfinityError(classified.message, classified.extensions?.code, classified.extensions?.status);
|
|
489
|
+
decorated.extensions = { ...classified.extensions, operationIndex, operation };
|
|
490
|
+
Object.defineProperty(decorated, 'cause', {
|
|
491
|
+
value: cause, writable: true, configurable: true, enumerable: false,
|
|
492
|
+
});
|
|
493
|
+
if (decorated instanceof InternalServerError) {
|
|
494
|
+
if (!(cause instanceof Error)) decorated.getCause = () => undefined;
|
|
495
|
+
} else if (typeof cause?.getCause === 'function') {
|
|
496
|
+
decorated.getCause = () => cause.getCause();
|
|
451
497
|
}
|
|
452
|
-
return
|
|
498
|
+
return decorated;
|
|
453
499
|
};
|
|
454
500
|
|
|
455
501
|
export const buildErrorFormatter = (callback) => (error) => {
|
|
@@ -544,6 +590,7 @@ const attemptInputs = (input, type) => {
|
|
|
544
590
|
};
|
|
545
591
|
|
|
546
592
|
export const createRuntime = (adapter) => {
|
|
593
|
+
const afterCommit = createAfterCommit(adapter);
|
|
547
594
|
if (!adapter) {
|
|
548
595
|
throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
|
|
549
596
|
}
|
|
@@ -561,12 +608,19 @@ export const createRuntime = (adapter) => {
|
|
|
561
608
|
|
|
562
609
|
const stateValue = adapter.stateValue || ((state) => state.name);
|
|
563
610
|
const runtimeIdentity = {};
|
|
611
|
+
// The identity that execution states (execution-state.js) compare, so that a session lent to another
|
|
612
|
+
// runtime finds no state.
|
|
613
|
+
const runtimeToken = {};
|
|
564
614
|
const typesDict = { types: {} };
|
|
565
615
|
const waitingInputType = {};
|
|
566
616
|
const typesDictForUpdate = { types: {} };
|
|
567
617
|
// `added` item inputs of referenced collections by generated name, so collections that reach the
|
|
568
618
|
// same child through the same connectionField share one input type.
|
|
569
619
|
const excludedFieldInputTypes = new Map();
|
|
620
|
+
// The operation inputs of referenced collections (the types with `added`, `updated` and `deleted`
|
|
621
|
+
// that createOneToManyInputType builds). The typed input walk (operation-input.js) knows them by
|
|
622
|
+
// identity, to find the record keys inside them: `updated[].id` and each `deleted` item.
|
|
623
|
+
const collectionInputTypes = new WeakSet();
|
|
570
624
|
// Self-referencing collection field name -> the types whose collection inputs were built with the
|
|
571
625
|
// unqualified names OneToManyA<field> and OneToManyU<field>; type -> its self-referencing
|
|
572
626
|
// collections whose inputs are named after the type instead (see claimSelfCollectionNames).
|
|
@@ -903,7 +957,7 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
|
|
|
903
957
|
+ `${itemType.name} records with a registered mutation.`,
|
|
904
958
|
};
|
|
905
959
|
|
|
906
|
-
|
|
960
|
+
const collectionInput = new GraphQLInputObjectType({
|
|
907
961
|
name,
|
|
908
962
|
fields: () => {
|
|
909
963
|
const fields = {};
|
|
@@ -926,6 +980,8 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
|
|
|
926
980
|
return fields;
|
|
927
981
|
},
|
|
928
982
|
});
|
|
983
|
+
collectionInputTypes.add(collectionInput);
|
|
984
|
+
return collectionInput;
|
|
929
985
|
};
|
|
930
986
|
|
|
931
987
|
// What graphQLListInputType returns for a list of an embedded type whose input has no fields.
|
|
@@ -1507,6 +1563,7 @@ const assertRecordVersion = async (gqltype, id, record, expectedVersion, advance
|
|
|
1507
1563
|
// deletes it only at that version, and a record it deleted at another one, which an adapter wrapper
|
|
1508
1564
|
// that drops the options would delete, fails with DATABASE_ERROR and rolls back with the transaction.
|
|
1509
1565
|
const onDelete = async (Model, controller, id, session, context, current, precondition = null) => {
|
|
1566
|
+
afterCommit.validate(controller);
|
|
1510
1567
|
const hasHook = !!(controller && controller.onDelete);
|
|
1511
1568
|
let currentObject = current;
|
|
1512
1569
|
if (currentObject === undefined && (hasHook || !readByIdIsInert(Model))) {
|
|
@@ -1625,6 +1682,7 @@ const setsVersion = (update) => [update, ...Object.entries(update)
|
|
|
1625
1682
|
// cannot set the version, and a result that did not advance it fails with DATABASE_ERROR.
|
|
1626
1683
|
const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context,
|
|
1627
1684
|
recordChecked = false, { expectedVersion, observedVersion, record } = {}) => {
|
|
1685
|
+
afterCommit.validate(controller);
|
|
1628
1686
|
const versioned = isProtected(gqltype);
|
|
1629
1687
|
const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
|
|
1630
1688
|
const objectId = args.id;
|
|
@@ -1712,6 +1770,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
1712
1770
|
// A protected type's version is captured before the action can change `args`, and checked on the
|
|
1713
1771
|
// plain pre-read record before the from-state check, which reads `state` from that record (#98).
|
|
1714
1772
|
const onStateChanged = async (Model, gqltype, controller, args, session, actionField, context) => {
|
|
1773
|
+
afterCommit.validate(controller);
|
|
1715
1774
|
const versioned = isProtected(gqltype);
|
|
1716
1775
|
const expectedVersion = versioned ? assertVersionInput(args.version) : undefined;
|
|
1717
1776
|
const storedModel = versioned
|
|
@@ -1738,6 +1797,7 @@ const onStateChanged = async (Model, gqltype, controller, args, session, actionF
|
|
|
1738
1797
|
};
|
|
1739
1798
|
|
|
1740
1799
|
const onSaveObject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
|
|
1800
|
+
afterCommit.validate(controller);
|
|
1741
1801
|
const materializedModel = await materializeModel(args, gqltype, linkToParent, 'CREATE', session);
|
|
1742
1802
|
if (typesDict.types[gqltype.name].stateMachine) {
|
|
1743
1803
|
materializedModel.modelArgs.state = stateValue(
|
|
@@ -1772,13 +1832,144 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
|
|
|
1772
1832
|
return result;
|
|
1773
1833
|
};
|
|
1774
1834
|
|
|
1835
|
+
// saveObject() and the lifecycle helpers look up, at entry, the execution state that a transaction
|
|
1836
|
+
// attempt (#151) attached to the session they were given. When the adapter's withTransaction hands the
|
|
1837
|
+
// body another object than that session, the state follows the body's object for as long as the body
|
|
1838
|
+
// runs, so every nested entry the body runs counts against the attempt's budget, whatever object the
|
|
1839
|
+
// adapter uses. Attachments are counted (execution-state.js): an object that already carries the state,
|
|
1840
|
+
// from an enclosing body or from a body running at the same time, keeps it until the last of them ends,
|
|
1841
|
+
// and one that carries another state gets that state back when this body ends, as long as the bodies on
|
|
1842
|
+
// that object end in the reverse order they started (nested, or one at a time). When bodies of different
|
|
1843
|
+
// states interleave on one object, a body that ends releases the latest attachment of its state, which
|
|
1844
|
+
// another body may have made, so the object can carry another state while a body of this one still runs.
|
|
1845
|
+
// Nothing is attached without a state, nor to an object that is not one.
|
|
1846
|
+
const runInExecutionState = (state, session, bodySession, run) => {
|
|
1847
|
+
if (state === undefined || bodySession === session || bodySession === null
|
|
1848
|
+
|| (typeof bodySession !== 'object' && typeof bodySession !== 'function')) {
|
|
1849
|
+
return run();
|
|
1850
|
+
}
|
|
1851
|
+
attachExecutionState(bodySession, state);
|
|
1852
|
+
return (async () => {
|
|
1853
|
+
try {
|
|
1854
|
+
return await run();
|
|
1855
|
+
} finally {
|
|
1856
|
+
detachExecutionState(bodySession, state);
|
|
1857
|
+
}
|
|
1858
|
+
})();
|
|
1859
|
+
};
|
|
1860
|
+
|
|
1775
1861
|
const saveObject = async (typeName, args, session, context) => {
|
|
1776
1862
|
const type = typesDict.types[typeName];
|
|
1777
1863
|
assertCollectionWrites(type.gqltype, args);
|
|
1778
1864
|
const snapshot = cloneInput(args, type.inputType);
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1865
|
+
const state = executionStateOf(session, runtimeToken);
|
|
1866
|
+
return adapter.withTransaction(session, (transaction) => runInExecutionState(state, session, transaction,
|
|
1867
|
+
() => runOperation({ Model: type.model, gqltype: type.gqltype, controller: type.controller }, operations.SAVE,
|
|
1868
|
+
cloneInput(snapshot, type.inputType), transaction, context)), type.model);
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1871
|
+
// The lifecycle helpers (#151): updateObject, deleteObject and transitionObject run the lifecycle of the
|
|
1872
|
+
// generated update, delete and state action mutations from code, through runOperation, as those roots
|
|
1873
|
+
// do, but without the root record's global middleware and without GraphQL. They check their target, then
|
|
1874
|
+
// validate the caller's input and take a snapshot of it before anything runs; every attempt of the
|
|
1875
|
+
// transaction works on its own copy of that snapshot, and the caller's input is never written to.
|
|
1876
|
+
|
|
1877
|
+
// How a type or action name that is not a string appears in a message, which never shows a value.
|
|
1878
|
+
const operationName = (value) => (typeof value === 'string' ? value : `(${value === null ? 'null' : typeof value})`);
|
|
1879
|
+
const notStoredTargetError = (typeName) => new SimfinityError(
|
|
1880
|
+
`Type ${operationName(typeName)} is not a stored record type registered in this runtime`, 'INVALID_OPERATION_TARGET', 500,
|
|
1881
|
+
);
|
|
1882
|
+
const noUpdateInputError = (typeName) => new SimfinityError(
|
|
1883
|
+
`Type ${typeName} has no prepared update input with an id field`, 'INVALID_OPERATION_TARGET', 500,
|
|
1884
|
+
);
|
|
1885
|
+
|
|
1886
|
+
// The registration a helper works on: a type registered in this runtime that stores records of its own
|
|
1887
|
+
// and has its model. An update or a transition also needs the type's generated update input, which only
|
|
1888
|
+
// createSchema() builds, with an `id` field, which a readOnly id or an input without fields (#161) lacks;
|
|
1889
|
+
// it gets the update registration, which holds that input. createSchema() also decides which types store
|
|
1890
|
+
// records, so a missing input is reported first: before createSchema(), the error names what is missing.
|
|
1891
|
+
const resolveOperationTarget = (typeName, { update }) => {
|
|
1892
|
+
const registration = typeof typeName === 'string' && Object.hasOwn(typesDict.types, typeName)
|
|
1893
|
+
? typesDict.types[typeName] : undefined;
|
|
1894
|
+
if (!registration) throw notStoredTargetError(typeName);
|
|
1895
|
+
const updateRegistration = typesDictForUpdate.types[typeName];
|
|
1896
|
+
if (update && !updateRegistration?.inputType) throw noUpdateInputError(typeName);
|
|
1897
|
+
if (!registration.model || registration.storedIdentity !== true) throw notStoredTargetError(typeName);
|
|
1898
|
+
if (update && !Object.hasOwn(updateRegistration.inputType.getFields(), 'id')) throw noUpdateInputError(typeName);
|
|
1899
|
+
return update ? updateRegistration : registration;
|
|
1900
|
+
};
|
|
1901
|
+
|
|
1902
|
+
// The input walk (operation-input.js) knows these inputs by identity, to find the record keys inside them:
|
|
1903
|
+
// `updated[].id` and each `deleted` item of the generated collection inputs, the id of a single reference
|
|
1904
|
+
// and the id of a versioned nested delete.
|
|
1905
|
+
const operationWalkOptions = { collectionInputTypes, idInputType: IdInputType, recordVersionRef: RecordVersionRef };
|
|
1906
|
+
|
|
1907
|
+
// The look-ahead of a transaction attempt (#151): the nested entries of `input` against what the attempt
|
|
1908
|
+
// has left of the limit it snapshotted, without counting them. No limit, no check.
|
|
1909
|
+
const assertRemainingNestedBudget = (gqltype, input, state) => {
|
|
1910
|
+
const remaining = remainingNestedBudget(state);
|
|
1911
|
+
if (remaining === null) return;
|
|
1912
|
+
const limit = state.limits.maxNestedOperations;
|
|
1913
|
+
assertNestedOperations(gqltype, input, limit, { count: limit - remaining });
|
|
1914
|
+
};
|
|
1915
|
+
|
|
1916
|
+
// The nested budget of a helper call. Outside a transaction attempt, the preflight is the whole budget,
|
|
1917
|
+
// with the limit configured when the helper is called. Inside one, it is the attempt's look-ahead.
|
|
1918
|
+
// Either way it only looks ahead and counts nothing.
|
|
1919
|
+
const assertHelperNestedBudget = (gqltype, snapshot, state) => {
|
|
1920
|
+
if (state !== undefined) {
|
|
1921
|
+
assertRemainingNestedBudget(gqltype, snapshot, state);
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
const limit = getMaxNestedOperations();
|
|
1925
|
+
if (limit !== null) assertNestedOperations(gqltype, snapshot, limit);
|
|
1926
|
+
};
|
|
1927
|
+
|
|
1928
|
+
// The transaction of a helper: the adapter's own when no session is supplied, retries included; with a
|
|
1929
|
+
// session, the adapter checks it, and the helper neither commits, retries, aborts nor cleans up.
|
|
1930
|
+
// `attemptArgs` gives each attempt its own arguments.
|
|
1931
|
+
const runHelperTransaction = (type, operation, attemptArgs, session, state, context, actionField = null, actionName) => adapter
|
|
1932
|
+
.withTransaction(session ?? null, (bodySession) => runInExecutionState(state, session, bodySession,
|
|
1933
|
+
() => runOperation({ Model: type.model, gqltype: type.gqltype, controller: type.controller }, operation,
|
|
1934
|
+
attemptArgs(), bodySession, context, actionField, actionName)), type.model);
|
|
1935
|
+
|
|
1936
|
+
// An update or a transition: the update input checks the whole input, whose top-level id is required and
|
|
1937
|
+
// whose state, when a state machine manages it, only a transition changes. The nested preflight and the
|
|
1938
|
+
// collection check run on the snapshot, before the transaction.
|
|
1939
|
+
const runHelperUpdate = (type, operation, input, session, context, actionField = null, actionName) => {
|
|
1940
|
+
const state = executionStateOf(session, runtimeToken);
|
|
1941
|
+
const snapshot = snapshotOperationInput(input, type.inputType, {
|
|
1942
|
+
...operationWalkOptions, mode: 'programmatic', path: 'input', topLevelId: true, stateManaged: !!type.stateMachine,
|
|
1943
|
+
});
|
|
1944
|
+
assertHelperNestedBudget(type.gqltype, snapshot, state);
|
|
1945
|
+
assertCollectionWrites(type.gqltype, snapshot);
|
|
1946
|
+
const attemptArgs = () => snapshotOperationInput(snapshot, type.inputType, { ...operationWalkOptions, mode: 'copy' });
|
|
1947
|
+
return runHelperTransaction(type, operation, attemptArgs, session, state, context, actionField, actionName);
|
|
1948
|
+
};
|
|
1949
|
+
|
|
1950
|
+
const updateObject = async (typeName, input, session, context) => runHelperUpdate(
|
|
1951
|
+
resolveOperationTarget(typeName, { update: true }), operations.UPDATE, input, session, context,
|
|
1952
|
+
);
|
|
1953
|
+
|
|
1954
|
+
const transitionObject = async (typeName, actionName, input, session, context) => {
|
|
1955
|
+
const type = resolveOperationTarget(typeName, { update: true });
|
|
1956
|
+
const actions = type.stateMachine?.actions;
|
|
1957
|
+
if (typeof actionName !== 'string' || !actions || !Object.hasOwn(actions, actionName)) {
|
|
1958
|
+
throw new SimfinityError(`Type ${typeName} has no state machine action ${operationName(actionName)}`,
|
|
1959
|
+
'UNKNOWN_ACTION', 500);
|
|
1960
|
+
}
|
|
1961
|
+
return runHelperUpdate(type, operations.STATE_CHANGED, input, session, context, actions[actionName], actionName);
|
|
1962
|
+
};
|
|
1963
|
+
|
|
1964
|
+
// The target is { id } or, for a protected type, { id, version }; the delete takes the bare ID or a new
|
|
1965
|
+
// { id, version } on each attempt, as the delete root passes them. A delete has no nested entries.
|
|
1966
|
+
const deleteObject = async (typeName, target, session, context) => {
|
|
1967
|
+
const type = resolveOperationTarget(typeName, { update: false });
|
|
1968
|
+
const versioned = isProtected(type.gqltype);
|
|
1969
|
+
const state = executionStateOf(session, runtimeToken);
|
|
1970
|
+
const { id, version } = readDeleteTarget(target, { protectedType: versioned, path: 'input' });
|
|
1971
|
+
const attemptArgs = versioned ? () => ({ id, version }) : () => id;
|
|
1972
|
+
return runHelperTransaction(type, operations.DELETE, attemptArgs, session, state, context);
|
|
1782
1973
|
};
|
|
1783
1974
|
|
|
1784
1975
|
// Counts the added, updated and deleted entries of referenced collections, at every nesting level that
|
|
@@ -1794,10 +1985,7 @@ const assertNestedOperations = (gqltype, input, limit, state = { count: 0 }) =>
|
|
|
1794
1985
|
for (const key of ['added', 'updated', 'deleted']) {
|
|
1795
1986
|
if (!Array.isArray(value[key])) continue;
|
|
1796
1987
|
state.count += value[key].length;
|
|
1797
|
-
if (state.count > limit)
|
|
1798
|
-
throw new SimfinityError(`A mutation may perform at most ${limit} nested collection operations`,
|
|
1799
|
-
'NESTED_OPERATIONS_EXCEEDED', 400);
|
|
1800
|
-
}
|
|
1988
|
+
if (state.count > limit) throw nestedOperationsExceededError(limit);
|
|
1801
1989
|
if (key !== 'deleted') {
|
|
1802
1990
|
for (const item of value[key]) assertNestedOperations(listShape.itemType, item, limit, state);
|
|
1803
1991
|
}
|
|
@@ -1856,11 +2044,49 @@ const preReadTopLevel = async (Model, gqltype, { id, version }, advances, contex
|
|
|
1856
2044
|
return { expectedVersion, observedVersion, record };
|
|
1857
2045
|
};
|
|
1858
2046
|
|
|
2047
|
+
// Runs one operation on a session that already exists: for the roots, through executeOperation, and for
|
|
2048
|
+
// the lifecycle helpers and the generated transaction (#151). A delete takes the bare ID, or
|
|
2049
|
+
// { id, version } for a protected type; every other operation takes its attempt's copy of the input.
|
|
2050
|
+
// The runner captures the version precondition from those args: an update or delete through
|
|
2051
|
+
// preReadTopLevel, a transition through onStateChanged. No caller captures it a second time.
|
|
2052
|
+
const runOperation = async ({ Model, gqltype, controller }, operation, attemptArgs, session, context,
|
|
2053
|
+
actionField = null, actionName) => {
|
|
2054
|
+
let record;
|
|
2055
|
+
switch (operation) {
|
|
2056
|
+
case operations.SAVE:
|
|
2057
|
+
record = await onSaveObject(Model, gqltype, controller, attemptArgs, session, null, context);
|
|
2058
|
+
break;
|
|
2059
|
+
case operations.UPDATE: {
|
|
2060
|
+
const precondition = isProtected(gqltype)
|
|
2061
|
+
? await preReadTopLevel(Model, gqltype, attemptArgs, true, context, session) : undefined;
|
|
2062
|
+
record = await onUpdateSubject(Model, gqltype, controller, attemptArgs, session, null, context, false, precondition);
|
|
2063
|
+
break;
|
|
2064
|
+
}
|
|
2065
|
+
case operations.DELETE: {
|
|
2066
|
+
if (!isProtected(gqltype)) record = await onDelete(Model, controller, attemptArgs, session, context);
|
|
2067
|
+
else {
|
|
2068
|
+
const { expectedVersion, record: current } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
|
|
2069
|
+
session);
|
|
2070
|
+
record = await onDelete(Model, controller, attemptArgs.id, session, context, current, { gqltype, expectedVersion });
|
|
2071
|
+
}
|
|
2072
|
+
break;
|
|
2073
|
+
}
|
|
2074
|
+
case operations.STATE_CHANGED:
|
|
2075
|
+
record = await onStateChanged(Model, gqltype, controller, attemptArgs, session, actionField, context);
|
|
2076
|
+
break;
|
|
2077
|
+
default:
|
|
2078
|
+
return null;
|
|
2079
|
+
}
|
|
2080
|
+
afterCommit.queue({ typeName: gqltype.name, controller, operation, record, args: attemptArgs,
|
|
2081
|
+
context, session, action: actionName });
|
|
2082
|
+
return record;
|
|
2083
|
+
};
|
|
2084
|
+
|
|
1859
2085
|
// A delete passes the ID, or { id, version } for a protected type, which each attempt copies; every
|
|
1860
2086
|
// other operation passes its input, which attemptInputs copies. Either way, a retry checks the version
|
|
1861
2087
|
// the request sent.
|
|
1862
2088
|
const executeOperation = (Model, gqltype, controller, args, operation, actionField,
|
|
1863
|
-
session, context, inputType) => {
|
|
2089
|
+
session, context, inputType, actionName) => {
|
|
1864
2090
|
const limit = getMaxNestedOperations();
|
|
1865
2091
|
if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
|
|
1866
2092
|
if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
|
|
@@ -1870,29 +2096,8 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
|
|
|
1870
2096
|
else if (versioned) nextInput = () => ({ ...args });
|
|
1871
2097
|
return adapter.withTransaction(
|
|
1872
2098
|
session,
|
|
1873
|
-
async (mySession) => {
|
|
1874
|
-
|
|
1875
|
-
switch (operation) {
|
|
1876
|
-
case operations.SAVE:
|
|
1877
|
-
return onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
|
|
1878
|
-
case operations.UPDATE: {
|
|
1879
|
-
const precondition = versioned
|
|
1880
|
-
? await preReadTopLevel(Model, gqltype, attemptArgs, true, context, mySession) : undefined;
|
|
1881
|
-
return onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context, false, precondition);
|
|
1882
|
-
}
|
|
1883
|
-
case operations.DELETE: {
|
|
1884
|
-
if (!versioned) return onDelete(Model, controller, attemptArgs, mySession, context);
|
|
1885
|
-
const { expectedVersion, record } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
|
|
1886
|
-
mySession);
|
|
1887
|
-
return onDelete(Model, controller, attemptArgs.id, mySession, context, record, { gqltype, expectedVersion });
|
|
1888
|
-
}
|
|
1889
|
-
case operations.STATE_CHANGED:
|
|
1890
|
-
return onStateChanged(Model, gqltype, controller,
|
|
1891
|
-
attemptArgs, mySession, actionField, context);
|
|
1892
|
-
default:
|
|
1893
|
-
return null;
|
|
1894
|
-
}
|
|
1895
|
-
},
|
|
2099
|
+
async (mySession) => runOperation({ Model, gqltype, controller }, operation, nextInput(), mySession, context,
|
|
2100
|
+
actionField, actionName),
|
|
1896
2101
|
Model,
|
|
1897
2102
|
);
|
|
1898
2103
|
};
|
|
@@ -1921,7 +2126,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
|
|
|
1921
2126
|
// A protected child is updated or deleted at the version its entry carries (#98). A deleted entry is
|
|
1922
2127
|
// { id, version } for item middleware; one that is not an object, such as a string ID, has no version.
|
|
1923
2128
|
const versioned = isProtected(collectionGQLType) && operationType !== operations.SAVE;
|
|
2129
|
+
// On the session of a transaction attempt (#151), every entry counts against the attempt's joint
|
|
2130
|
+
// budget before it runs, a null one included; elsewhere nothing is counted.
|
|
2131
|
+
const executionState = executionStateOf(session, runtimeToken);
|
|
1924
2132
|
for (const element of collectionFieldsList) {
|
|
2133
|
+
if (executionState !== undefined) countNestedEntry(executionState);
|
|
1925
2134
|
if (element === null) continue;
|
|
1926
2135
|
let args = { input: element };
|
|
1927
2136
|
if (operationType === operations.DELETE) {
|
|
@@ -1956,19 +2165,22 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
|
|
|
1956
2165
|
}
|
|
1957
2166
|
}
|
|
1958
2167
|
|
|
2168
|
+
let record;
|
|
1959
2169
|
switch (operationType) {
|
|
1960
2170
|
case operations.SAVE:
|
|
1961
|
-
await onSaveObject(type.model, collectionGQLType, type.controller,
|
|
2171
|
+
record = await onSaveObject(type.model, collectionGQLType, type.controller,
|
|
1962
2172
|
args.input, session, linkToParent, context);
|
|
1963
2173
|
break;
|
|
1964
2174
|
case operations.UPDATE:
|
|
1965
|
-
await onUpdateSubject(type.model, collectionGQLType, type.controller,
|
|
2175
|
+
record = await onUpdateSubject(type.model, collectionGQLType, type.controller,
|
|
1966
2176
|
args.input, session, linkToParent, context, true, versioned ? { expectedVersion, observedVersion, record: child } : undefined);
|
|
1967
2177
|
break;
|
|
1968
2178
|
case operations.DELETE:
|
|
1969
|
-
await onDelete(type.model, type.controller, args.id, session, context, child,
|
|
2179
|
+
record = await onDelete(type.model, type.controller, args.id, session, context, child,
|
|
1970
2180
|
versioned ? { gqltype: collectionGQLType, expectedVersion } : null);
|
|
1971
2181
|
}
|
|
2182
|
+
afterCommit.queue({ typeName: collectionGQLType.name, controller: type.controller,
|
|
2183
|
+
operation: operationType, record, args: args.input, context, session });
|
|
1972
2184
|
}
|
|
1973
2185
|
};
|
|
1974
2186
|
|
|
@@ -3035,8 +3247,9 @@ const buildRegisteredOperationField = (entry, registration, operation) => {
|
|
|
3035
3247
|
// classify it without guessing from its name. Extensions do not appear in SDL or introspection.
|
|
3036
3248
|
// An input object needs a field, so a type whose create input has none gets no add mutation, and
|
|
3037
3249
|
// one whose update input has none, which only a type without a writable id can have, gets no update
|
|
3038
|
-
// mutation or state machine actions either (#161).
|
|
3039
|
-
|
|
3250
|
+
// mutation or state machine actions either (#161). A registered mutation replaces a generated root of
|
|
3251
|
+
// the same name; each replaced root is added to `shadowedRoots` as `{ entry, typeName }`.
|
|
3252
|
+
const buildMutation = (name, includedMutationTypes, includedCustomMutations, shadowedRoots = []) => {
|
|
3040
3253
|
const rootQueryArgs = {};
|
|
3041
3254
|
rootQueryArgs.name = name;
|
|
3042
3255
|
rootQueryArgs.fields = {};
|
|
@@ -3158,7 +3371,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
3158
3371
|
await executeMiddleware(params);
|
|
3159
3372
|
return executeOperation(type.model, type.gqltype, type.controller,
|
|
3160
3373
|
args.input, operations.STATE_CHANGED, actionField,
|
|
3161
|
-
null, context, type.inputType);
|
|
3374
|
+
null, context, type.inputType, actionName);
|
|
3162
3375
|
},
|
|
3163
3376
|
};
|
|
3164
3377
|
}
|
|
@@ -3170,6 +3383,8 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
3170
3383
|
|
|
3171
3384
|
for (const [entry, registeredMutation] of Object.entries(registeredMutations)) {
|
|
3172
3385
|
if (!shouldNotBeIncludedInSchema(includedCustomMutations, entry)) {
|
|
3386
|
+
const generated = Object.hasOwn(rootQueryArgs.fields, entry) && rootQueryArgs.fields[entry].extensions?.simfinityMutation;
|
|
3387
|
+
if (generated?.typeName) shadowedRoots.push({ entry, typeName: generated.typeName });
|
|
3173
3388
|
rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredMutation,
|
|
3174
3389
|
operations.CUSTOM_MUTATION);
|
|
3175
3390
|
}
|
|
@@ -3872,11 +4087,46 @@ const invalidSchemaOptions = (detail) => new SimfinityError(
|
|
|
3872
4087
|
`createSchema options ${detail}`, 'INVALID_SCHEMA_OPTIONS', 500,
|
|
3873
4088
|
);
|
|
3874
4089
|
|
|
3875
|
-
const SCHEMA_OPTION_KEYS = new Set(['includedCustomQueries']);
|
|
4090
|
+
const SCHEMA_OPTION_KEYS = new Set(['includedCustomQueries', 'transaction']);
|
|
4091
|
+
|
|
4092
|
+
// The root names an included endpoint type's generated mutations can have, whether or not its inputs
|
|
4093
|
+
// leave some of them out.
|
|
4094
|
+
const generatedMutationNames = ({ simpleEntityEndpointName: endpoint, stateMachine }) => [
|
|
4095
|
+
`add${endpoint}`, `delete${endpoint}`, `update${endpoint}`,
|
|
4096
|
+
...Object.keys(stateMachine?.actions ?? {}).map((actionName) => `${actionName}_${endpoint}`),
|
|
4097
|
+
];
|
|
4098
|
+
|
|
4099
|
+
// The generated transaction field (#151), checked before anything is prepared: the option's shape, its
|
|
4100
|
+
// types (registered in this runtime), the adapter's support, the field name against the included
|
|
4101
|
+
// registered mutations and every root the included endpoint types can generate, and whether any of
|
|
4102
|
+
// its types is an included endpoint type, which always generates a delete root. Returns null or
|
|
4103
|
+
// `{ name, types }`.
|
|
4104
|
+
const readTransactionConfig = (option, includedMutationTypes, includedCustomMutations) => {
|
|
4105
|
+
const config = readTransactionOption(option);
|
|
4106
|
+
if (!config) return null;
|
|
4107
|
+
for (const type of config.types) {
|
|
4108
|
+
assertNotBoundElsewhere(type);
|
|
4109
|
+
if (typesDict.types[type.name]?.gqltype !== type) {
|
|
4110
|
+
throw invalidTransactionOption(`${type.name} is not registered in this runtime`);
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
if (typeof adapter.assertTransactionModels !== 'function') throw transactionUnsupportedError();
|
|
4114
|
+
const { name, types } = config;
|
|
4115
|
+
if (Object.hasOwn(registeredMutations, name) && !shouldNotBeIncludedInSchema(includedCustomMutations, name)) {
|
|
4116
|
+
throw invalidTransactionOption(`name ${name} conflicts with the registered mutation ${name}`);
|
|
4117
|
+
}
|
|
4118
|
+
const included = Object.values(typesDict.types)
|
|
4119
|
+
.filter((type) => type.endpoint && !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype));
|
|
4120
|
+
if (included.some((type) => generatedMutationNames(type).includes(name))) {
|
|
4121
|
+
throw invalidTransactionOption(`name ${name} conflicts with a generated mutation`);
|
|
4122
|
+
}
|
|
4123
|
+
if (!included.some((type) => types.includes(type.gqltype))) throw noOperationError();
|
|
4124
|
+
return config;
|
|
4125
|
+
};
|
|
3876
4126
|
|
|
3877
4127
|
// The fourth createSchema() argument: omitted, or a plain object with known keys only.
|
|
3878
|
-
const readSchemaOptions = (options) => {
|
|
3879
|
-
if (options === undefined) return {};
|
|
4128
|
+
const readSchemaOptions = (options, includedMutationTypes, includedCustomMutations) => {
|
|
4129
|
+
if (options === undefined) return { transaction: null };
|
|
3880
4130
|
if (options === null || typeof options !== 'object' || !isPlainObject(options)) {
|
|
3881
4131
|
throw invalidSchemaOptions('must be a plain object');
|
|
3882
4132
|
}
|
|
@@ -3889,12 +4139,410 @@ const readSchemaOptions = (options) => {
|
|
|
3889
4139
|
|| includedCustomQueries.some((name) => typeof name !== 'string'))) {
|
|
3890
4140
|
throw invalidSchemaOptions('includedCustomQueries must be an array of strings, null or undefined');
|
|
3891
4141
|
}
|
|
3892
|
-
|
|
4142
|
+
const transaction = readTransactionConfig(options.transaction, includedMutationTypes, includedCustomMutations);
|
|
4143
|
+
return { includedCustomQueries, transaction };
|
|
4144
|
+
};
|
|
4145
|
+
|
|
4146
|
+
// The types whose collections a transaction's references count (planReferences, item 4): every type
|
|
4147
|
+
// registered in this runtime.
|
|
4148
|
+
const referenceRegistry = { registeredTypes: () => Object.values(typesDict.types).map(({ gqltype }) => gqltype) };
|
|
4149
|
+
|
|
4150
|
+
// The model affinity check (#151), before the session opens, so that models that cannot share one database
|
|
4151
|
+
// transaction fail before anything runs. The adapter's check is synchronous: a thenable it returns is
|
|
4152
|
+
// refused, once the rejection of a native promise is handled so it cannot crash the process. An error it
|
|
4153
|
+
// throws passes through unchanged.
|
|
4154
|
+
const assertTransactionModels = (models) => {
|
|
4155
|
+
if (typeof adapter.assertTransactionModels !== 'function') throw transactionUnsupportedError();
|
|
4156
|
+
const result = adapter.assertTransactionModels(models);
|
|
4157
|
+
if (isThenable(result)) {
|
|
4158
|
+
discardThenable(result);
|
|
4159
|
+
throw new SimfinityError('assertTransactionModels must be synchronous', 'TRANSACTION_UNSUPPORTED', 500);
|
|
4160
|
+
}
|
|
4161
|
+
};
|
|
4162
|
+
|
|
4163
|
+
// An error of the input walk of one operation, as a new SimfinityError with the same message, code and
|
|
4164
|
+
// status that names the operation, as planReferences reports its errors. Any other error is not the
|
|
4165
|
+
// input's and passes through.
|
|
4166
|
+
const operationInputError = (error, index, key) => {
|
|
4167
|
+
if (!(error instanceof SimfinityError)) return error;
|
|
4168
|
+
const positioned = new SimfinityError(error.message, error.extensions.code, error.extensions.status);
|
|
4169
|
+
positioned.extensions.operationIndex = index;
|
|
4170
|
+
positioned.extensions.operation = key;
|
|
4171
|
+
return positioned;
|
|
4172
|
+
};
|
|
4173
|
+
|
|
4174
|
+
// The steps of the list GraphQL coerced, `{ index, key, entry }`, one per item. GraphQL has checked the
|
|
4175
|
+
// items (@oneOf), so each has exactly one of the field's operation keys.
|
|
4176
|
+
const transactionSteps = (transactionState, operationList) => operationList.map((item, index) => {
|
|
4177
|
+
const keys = item !== null && typeof item === 'object' ? Object.keys(item) : [];
|
|
4178
|
+
const entry = keys.length === 1 ? transactionState.operations.get(keys[0]) : undefined;
|
|
4179
|
+
if (entry === undefined) throw new TypeError(`Item ${index} is not an operation of ${transactionState.name}`);
|
|
4180
|
+
return { index, key: keys[0], entry };
|
|
4181
|
+
});
|
|
4182
|
+
|
|
4183
|
+
// The input walk over the list GraphQL coerced, which applies the scalar and record-key rules, bounds the
|
|
4184
|
+
// nesting and requires the top-level id of an update, an action or a delete. Returns the snapshot: one
|
|
4185
|
+
// `{ [key]: value }` per item, which the list never shares.
|
|
4186
|
+
const snapshotTransaction = (steps, operationList) => steps.map(({ index, key, entry }) => {
|
|
4187
|
+
try {
|
|
4188
|
+
return {
|
|
4189
|
+
[key]: snapshotOperationInput(operationList[index][key], entry.valueType, {
|
|
4190
|
+
...operationWalkOptions, mode: 'coerced', path: `operations[${index}].${key}`, topLevelId: entry.operation !== operations.SAVE,
|
|
4191
|
+
}),
|
|
4192
|
+
};
|
|
4193
|
+
} catch (error) {
|
|
4194
|
+
throw operationInputError(error, index, key);
|
|
4195
|
+
}
|
|
4196
|
+
});
|
|
4197
|
+
|
|
4198
|
+
// The nested entries of the whole list, nulls included, against the snapshotted limit; null is no limit.
|
|
4199
|
+
// `list` is the list GraphQL coerced, or a snapshot of it, which has the same entries. It only looks
|
|
4200
|
+
// ahead: the attempts count what runs. A delete has no nested entries.
|
|
4201
|
+
const assertTransactionNestedOperations = (steps, list, limit) => {
|
|
4202
|
+
if (limit === null) return;
|
|
4203
|
+
const counted = { count: 0 };
|
|
4204
|
+
for (const { index, key, entry } of steps) {
|
|
4205
|
+
if (entry.operation !== operations.DELETE) assertNestedOperations(entry.type.gqltype, list[index][key], limit, counted);
|
|
4206
|
+
}
|
|
4207
|
+
};
|
|
4208
|
+
|
|
4209
|
+
// What identifies the marker of a generated root by content: its type, operation and action, which a copy
|
|
4210
|
+
// of the marker (a spread or a deep clone) keeps. Anything else under the key identifies nothing.
|
|
4211
|
+
const rootMarkerIdentity = (marker) => {
|
|
4212
|
+
if (marker === null || typeof marker !== 'object') return undefined;
|
|
4213
|
+
const { typeName, operation, action } = marker;
|
|
4214
|
+
if (typeof typeName !== 'string' || typeof operation !== 'string'
|
|
4215
|
+
|| (action !== undefined && action !== null && typeof action !== 'string')) return undefined;
|
|
4216
|
+
return JSON.stringify([typeName, operation, action ?? null]);
|
|
4217
|
+
};
|
|
4218
|
+
|
|
4219
|
+
// An operation whose root the Mutation type that runs the transaction does not expose, positioned as the
|
|
4220
|
+
// input walk and the references report their errors.
|
|
4221
|
+
const unexposedOperationError = (mutationType, operationIndex, operation) => {
|
|
4222
|
+
const error = new ForbiddenError(`Access denied to ${mutationType.name}.${operation}`);
|
|
4223
|
+
error.extensions.operationIndex = operationIndex;
|
|
4224
|
+
error.extensions.operation = operation;
|
|
4225
|
+
return error;
|
|
4226
|
+
};
|
|
4227
|
+
|
|
4228
|
+
// Where each operation of the list may run: `mutationType`, the Mutation type that runs the
|
|
4229
|
+
// transaction (`info.parentType`), must expose its generated root, that is, have a field whose marker is
|
|
4230
|
+
// the marker of the root the field was built with, or a copy of it, under any name. A served copy of the
|
|
4231
|
+
// schema can drop a root, or put another field under its name, and a direct call could then not reach the
|
|
4232
|
+
// root there, so neither may an operation: the first operation of the list whose root is not exposed
|
|
4233
|
+
// fails with FORBIDDEN, `Access denied to <Mutation type>.<key>`, before the transaction.
|
|
4234
|
+
//
|
|
4235
|
+
// Returns operation key -> the roots whose checks the operation gets on every attempt: one per distinct
|
|
4236
|
+
// marker, by identity, among the markers of the fields that expose the root and the marker of the root the
|
|
4237
|
+
// field was built with. The identity field's marker comes first, then those of the other fields in field
|
|
4238
|
+
// order, then the built one. A copy that renames the root, aliases it or clones its markers thus runs the
|
|
4239
|
+
// checks of every name that reaches the root, and never fewer than the schema the field was built in: a
|
|
4240
|
+
// transform that clones the markers would otherwise drop the entries its copied wrappers still enforce.
|
|
4241
|
+
// authorizeOperation reads only a root's name, type and marker, so each root is a view of one marker with
|
|
4242
|
+
// the identity the rules see: the operation key as `info.fieldName`, and the type of the identity field,
|
|
4243
|
+
// which is the field that exposes the root under the key, or else the first field that exposes it, in field
|
|
4244
|
+
// order. The rules' `info.parentType` is the Mutation type that runs the transaction, whose fields they all
|
|
4245
|
+
// are.
|
|
4246
|
+
const exposedOperationRoots = (steps, mutationType) => {
|
|
4247
|
+
const exposing = new Map();
|
|
4248
|
+
for (const field of Object.values(mutationType.getFields())) {
|
|
4249
|
+
const identity = rootMarkerIdentity(field.extensions?.simfinityMutation);
|
|
4250
|
+
if (identity === undefined) continue;
|
|
4251
|
+
if (!exposing.has(identity)) exposing.set(identity, []);
|
|
4252
|
+
exposing.get(identity).push(field);
|
|
4253
|
+
}
|
|
4254
|
+
const roots = new Map();
|
|
4255
|
+
for (const { index, key, entry } of steps) {
|
|
4256
|
+
if (roots.has(key)) continue;
|
|
4257
|
+
const builtMarker = entry.rootField.extensions.simfinityMutation;
|
|
4258
|
+
const fields = exposing.get(rootMarkerIdentity(builtMarker)) ?? [];
|
|
4259
|
+
if (fields.length === 0) throw unexposedOperationError(mutationType, index, key);
|
|
4260
|
+
const identityField = fields.find((field) => field.name === key) ?? fields[0];
|
|
4261
|
+
const markers = new Set([identityField, ...fields].map((field) => field.extensions.simfinityMutation));
|
|
4262
|
+
markers.add(builtMarker);
|
|
4263
|
+
roots.set(key, [...markers].map((marker) => Object.freeze({
|
|
4264
|
+
name: key, type: identityField.type, extensions: Object.freeze({ simfinityMutation: marker }),
|
|
4265
|
+
})));
|
|
4266
|
+
}
|
|
4267
|
+
return roots;
|
|
4268
|
+
};
|
|
4269
|
+
|
|
4270
|
+
// The checks a direct call to an operation's root gets: the entries the auth plugin recorded
|
|
4271
|
+
// on each of the operation's roots (exposedOperationRoots), in order.
|
|
4272
|
+
const authorizeTransactionOperation = async (roots, parent, args, context, info) => {
|
|
4273
|
+
for (const root of roots) await authorizeOperation(root, parent, args, context, info);
|
|
4274
|
+
};
|
|
4275
|
+
|
|
4276
|
+
// Whether the error that ended the transaction is the one that ended the final attempt, `operationError`,
|
|
4277
|
+
// or an error an adapter made of it, such as TRANSACTION_RETRY_EXCEEDED, that keeps it in its `cause`
|
|
4278
|
+
// chain. Any other error, such as the failure of a begin, a commit, a rollback or a release, or one of an
|
|
4279
|
+
// earlier attempt, is not that operation's.
|
|
4280
|
+
const reachesOperationError = (error, operationError) => {
|
|
4281
|
+
if (error === operationError) return true;
|
|
4282
|
+
const seen = new Set();
|
|
4283
|
+
let current = error;
|
|
4284
|
+
while ((typeof current === 'object' && current !== null) || typeof current === 'function') {
|
|
4285
|
+
if (seen.has(current)) return false;
|
|
4286
|
+
seen.add(current);
|
|
4287
|
+
current = current.cause;
|
|
4288
|
+
if (current != null && current === operationError) return true;
|
|
4289
|
+
}
|
|
4290
|
+
return false;
|
|
4291
|
+
};
|
|
4292
|
+
|
|
4293
|
+
// The record of each result item of a transaction that has one, with the item's operation key and the type
|
|
4294
|
+
// that operation's root returned when the field was built: what TransactionResult.id resolves from. The
|
|
4295
|
+
// item of a delete of a missing record has none.
|
|
4296
|
+
const transactionResultRecords = new WeakMap();
|
|
4297
|
+
|
|
4298
|
+
// The record's type in the executing schema, whose fields a plugin applied to that schema wrapped: the
|
|
4299
|
+
// object type the executing result type gives the operation key, or else the executing schema's object
|
|
4300
|
+
// type of the name the root returned when the field was built. Undefined when it has neither: the type the
|
|
4301
|
+
// field was built with is never used, since a plugin applied to a copy with new type objects never wrapped it.
|
|
4302
|
+
const executingRecordType = (info, key, builtType) => {
|
|
4303
|
+
const resultFields = info.parentType.getFields();
|
|
4304
|
+
const executing = Object.hasOwn(resultFields, key) ? getNamedType(resultFields[key].type) : undefined;
|
|
4305
|
+
if (isObjectType(executing)) return executing;
|
|
4306
|
+
const named = info.schema.getType(builtType.name);
|
|
4307
|
+
return isObjectType(named) ? named : undefined;
|
|
4308
|
+
};
|
|
4309
|
+
|
|
4310
|
+
// TransactionResult.id: the id of the operation's record, resolved by the `id` field of the record's own
|
|
4311
|
+
// type in the executing schema (executingRecordType), with the record as parent, no arguments and that
|
|
4312
|
+
// field's info, as a direct call to the operation's root that selects `id` resolves it, so that a rule an
|
|
4313
|
+
// auth plugin put on `<Type>.id` applies here too. Without such a type the id is refused with FORBIDDEN
|
|
4314
|
+
// (null and the error). An item without a record, and a type without an `id` field, give null without
|
|
4315
|
+
// calling anything.
|
|
4316
|
+
const resolveTransactionResultId = (item, args, context, info) => {
|
|
4317
|
+
const result = item !== null && typeof item === 'object' ? transactionResultRecords.get(item) : undefined;
|
|
4318
|
+
if (result === undefined) return null;
|
|
4319
|
+
const { key, record, builtType } = result;
|
|
4320
|
+
const recordType = executingRecordType(info, key, builtType);
|
|
4321
|
+
if (recordType === undefined) throw new ForbiddenError(`Access denied to ${info.parentType.name}.id`);
|
|
4322
|
+
const recordFields = recordType.getFields();
|
|
4323
|
+
if (!Object.hasOwn(recordFields, 'id')) return null;
|
|
4324
|
+
const idField = recordFields.id;
|
|
4325
|
+
return (idField.resolve ?? defaultFieldResolver)(record, {}, context, {
|
|
4326
|
+
...info, fieldName: 'id', parentType: recordType, returnType: idField.type,
|
|
4327
|
+
});
|
|
4328
|
+
};
|
|
4329
|
+
|
|
4330
|
+
// Runs a non-empty operation list of a transaction field and returns one result per operation, `{ id,
|
|
4331
|
+
// [key]: record }`, where the record of a delete of a missing record and its id are null. Before the
|
|
4332
|
+
// transaction: the limits are read once, then come the operation count, the model affinity check, the
|
|
4333
|
+
// nested entries of the whole list, counted on what GraphQL coerced so that a list past the limit fails as
|
|
4334
|
+
// a root past it does, the input walk, the references and the roots the Mutation type that runs the
|
|
4335
|
+
// transaction exposes. Then one owned transaction runs the list, retries included; each
|
|
4336
|
+
// attempt works on its own copy of the snapshot, with an execution state on its session for the limits and
|
|
4337
|
+
// the joint nested budget. When the error of an operation of the final attempt ends the transaction, the
|
|
4338
|
+
// resolver reports it with that operation's position (decorateOperationError); every other error is
|
|
4339
|
+
// reported as it is.
|
|
4340
|
+
const executeTransaction = async (transactionState, operationList, parent, context, info) => {
|
|
4341
|
+
const limits = Object.freeze({
|
|
4342
|
+
maxNestedOperations: getMaxNestedOperations(), maxTransactionOperations: getMaxTransactionOperations(),
|
|
4343
|
+
});
|
|
4344
|
+
if (operationList.length > limits.maxTransactionOperations) {
|
|
4345
|
+
throw transactionOperationsExceededError(limits.maxTransactionOperations);
|
|
4346
|
+
}
|
|
4347
|
+
assertTransactionModels(transactionState.models);
|
|
4348
|
+
const steps = transactionSteps(transactionState, operationList);
|
|
4349
|
+
assertTransactionNestedOperations(steps, operationList, limits.maxNestedOperations);
|
|
4350
|
+
const snapshot = snapshotTransaction(steps, operationList);
|
|
4351
|
+
// Once, on the snapshot, which it changes in place: `$$` literals lose a `$` before any attempt copies them.
|
|
4352
|
+
const referencePlan = planReferences(snapshot, transactionState, referenceRegistry);
|
|
4353
|
+
// The plan's positions by operation index, in plan order, so that each operation reads only its own.
|
|
4354
|
+
const referencesOf = new Map();
|
|
4355
|
+
for (const position of referencePlan) {
|
|
4356
|
+
if (!referencesOf.has(position.index)) referencesOf.set(position.index, []);
|
|
4357
|
+
referencesOf.get(position.index).push(position);
|
|
4358
|
+
}
|
|
4359
|
+
const operationRoots = exposedOperationRoots(steps, info.parentType);
|
|
4360
|
+
|
|
4361
|
+
// One record per attempt that started, in order. Its `failure` is `{ error, operationIndex, operation }`
|
|
4362
|
+
// for the operation whose error ended the attempt, or null: errors propagate unchanged while attempts
|
|
4363
|
+
// run, so that the adapter can still recognize them, and the position of the final attempt's failure
|
|
4364
|
+
// stays here for the error the resolver reports.
|
|
4365
|
+
const attempts = [];
|
|
4366
|
+
const runAttempt = async (session) => {
|
|
4367
|
+
// The joint budget follows the attempt's session; one that cannot carry it would leave it unchecked.
|
|
4368
|
+
if (session === null || (typeof session !== 'object' && typeof session !== 'function')) {
|
|
4369
|
+
throw transactionUnsupportedError();
|
|
4370
|
+
}
|
|
4371
|
+
const attempt = { failure: null };
|
|
4372
|
+
attempts.push(attempt);
|
|
4373
|
+
const state = createExecutionState({ runtimeToken, limits });
|
|
4374
|
+
attachExecutionState(session, state);
|
|
4375
|
+
try {
|
|
4376
|
+
const copies = steps.map(({ index, key, entry }) => ({
|
|
4377
|
+
[key]: snapshotOperationInput(snapshot[index][key], entry.valueType, { ...operationWalkOptions, mode: 'copy' }),
|
|
4378
|
+
}));
|
|
4379
|
+
// `{ id }` of each operation's record in this attempt, or null, which resolveReferences reads.
|
|
4380
|
+
const written = [];
|
|
4381
|
+
// One operation: its references, the checks of its roots with the root's args,
|
|
4382
|
+
// `{ input }` or, for a delete, `{ id }` or `{ id, version }`, the root's middleware with the same
|
|
4383
|
+
// args, then, on a copy of what middleware left and before anything of it is written, the look-ahead
|
|
4384
|
+
// of its nested entries and the collection check, and the operation itself on the attempt's session.
|
|
4385
|
+
const runStep = async ({ index, key, entry }) => {
|
|
4386
|
+
const references = referencesOf.get(index);
|
|
4387
|
+
if (references !== undefined) resolveReferences(copies, references, written, index);
|
|
4388
|
+
const args = entry.operation === operations.DELETE ? copies[index][key] : { input: copies[index][key] };
|
|
4389
|
+
await authorizeTransactionOperation(operationRoots.get(key), parent, args, context, info);
|
|
4390
|
+
await executeMiddleware(entry.operation === operations.STATE_CHANGED
|
|
4391
|
+
? {
|
|
4392
|
+
type: entry.type, args, operation: entry.operation, actionName: entry.actionName, actionField: entry.actionField, context,
|
|
4393
|
+
}
|
|
4394
|
+
: {
|
|
4395
|
+
type: entry.type, args, operation: entry.operation, context,
|
|
4396
|
+
});
|
|
4397
|
+
const { model: Model, gqltype, controller } = entry.type;
|
|
4398
|
+
if (entry.operation === operations.DELETE) {
|
|
4399
|
+
// As the delete root: a protected delete takes the version as middleware left it.
|
|
4400
|
+
const target = entry.protected ? { id: args.id, version: args.version } : args.id;
|
|
4401
|
+
return runOperation({ Model, gqltype, controller }, entry.operation, target, session, context);
|
|
4402
|
+
}
|
|
4403
|
+
// The operation gets its own copy of the input middleware left, as each attempt of a root does, so
|
|
4404
|
+
// a value middleware put there, which may be shared, is never changed by it and a retry starts from it.
|
|
4405
|
+
const input = cloneInput(args.input, entry.valueType);
|
|
4406
|
+
// As the roots, the nested limit comes before the pre-read, so it wins over an early VERSION_CONFLICT.
|
|
4407
|
+
assertRemainingNestedBudget(gqltype, input, state);
|
|
4408
|
+
assertCollectionWrites(gqltype, input);
|
|
4409
|
+
return runOperation({ Model, gqltype, controller }, entry.operation, input, session, context,
|
|
4410
|
+
entry.actionField ?? null, entry.actionName);
|
|
4411
|
+
};
|
|
4412
|
+
const results = [];
|
|
4413
|
+
for (const step of steps) {
|
|
4414
|
+
let record;
|
|
4415
|
+
try {
|
|
4416
|
+
record = await runStep(step);
|
|
4417
|
+
} catch (error) {
|
|
4418
|
+
attempt.failure = { error, operationIndex: step.index, operation: step.key };
|
|
4419
|
+
throw error;
|
|
4420
|
+
}
|
|
4421
|
+
const id = record == null ? null : record._id ?? record.id ?? null;
|
|
4422
|
+
written[step.index] = record == null ? null : { id };
|
|
4423
|
+
const item = { id, [step.key]: record ?? null };
|
|
4424
|
+
if (record != null) {
|
|
4425
|
+
transactionResultRecords.set(item, {
|
|
4426
|
+
key: step.key, record, builtType: getNamedType(step.entry.rootField.type),
|
|
4427
|
+
});
|
|
4428
|
+
}
|
|
4429
|
+
results.push(item);
|
|
4430
|
+
}
|
|
4431
|
+
return results;
|
|
4432
|
+
} finally {
|
|
4433
|
+
detachExecutionState(session, state);
|
|
4434
|
+
}
|
|
4435
|
+
};
|
|
4436
|
+
try {
|
|
4437
|
+
return await adapter.withTransaction(null, runAttempt, steps[0].entry.type.model);
|
|
4438
|
+
} catch (error) {
|
|
4439
|
+
const { failure } = attempts.at(-1) ?? {};
|
|
4440
|
+
throw failure && reachesOperationError(error, failure.error)
|
|
4441
|
+
? decorateOperationError(error, failure.operationIndex, failure.operation) : error;
|
|
4442
|
+
}
|
|
4443
|
+
};
|
|
4444
|
+
|
|
4445
|
+
// The models one transaction of the field may write (#151): those of the types that own
|
|
4446
|
+
// its operations, then those of the referenced collections their inputs declare, through the items'
|
|
4447
|
+
// own inputs and embedded values at any depth, without null models or repeats.
|
|
4448
|
+
const transactionModels = (operationEntries) => {
|
|
4449
|
+
const models = [];
|
|
4450
|
+
const add = (model) => {
|
|
4451
|
+
if (model != null && !models.includes(model)) models.push(model);
|
|
4452
|
+
};
|
|
4453
|
+
const owners = [...new Set([...operationEntries.values()].map(({ type }) => typesDict.types[type.gqltype.name]))];
|
|
4454
|
+
owners.forEach(({ model }) => add(model));
|
|
4455
|
+
const visited = new Set();
|
|
4456
|
+
const visit = (gqltype) => {
|
|
4457
|
+
if (visited.has(gqltype)) return;
|
|
4458
|
+
visited.add(gqltype);
|
|
4459
|
+
for (const field of Object.values(gqltype.getFields())) {
|
|
4460
|
+
if (isReadOnlyField(field)) continue;
|
|
4461
|
+
const listShape = getListShape(field.type);
|
|
4462
|
+
const target = listShape ? listShape.itemType : unwrapNonNull(field.type);
|
|
4463
|
+
const registration = target instanceof GraphQLObjectType ? typesDict.types[target.name] : undefined;
|
|
4464
|
+
if (registration?.gqltype !== target) continue;
|
|
4465
|
+
const embedded = !!field.extensions?.relation?.embedded;
|
|
4466
|
+
if (listShape && !embedded) {
|
|
4467
|
+
add(registration.model);
|
|
4468
|
+
visit(target);
|
|
4469
|
+
} else if (embedded) {
|
|
4470
|
+
visit(target);
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
};
|
|
4474
|
+
owners.forEach(({ gqltype }) => visit(gqltype));
|
|
4475
|
+
return models;
|
|
4476
|
+
};
|
|
4477
|
+
|
|
4478
|
+
// The transaction field of a schema whose generated mutations are built, from the roots of `mutation`,
|
|
4479
|
+
// which does not have it yet, and `query`, the final query root. Returns `{ field, state }`, where the
|
|
4480
|
+
// state is what executeTransaction reads: `name`, `typeNames`, `operations` (operation key ->
|
|
4481
|
+
// `{ type, operation, actionName?, actionField?, protected, rootField, valueType }`, with the
|
|
4482
|
+
// registration the root runs: the update one for updates and actions; the root field the schema was
|
|
4483
|
+
// built with, whose authorization entries a copy of the schema cannot drop; and the type of the
|
|
4484
|
+
// operation's value in the field's operation type) and `models`, frozen, which the adapter's affinity
|
|
4485
|
+
// check receives.
|
|
4486
|
+
const buildTransaction = ({ name, types }, query, mutation, shadowedRoots) => {
|
|
4487
|
+
const typeNames = types.map((type) => type.name);
|
|
4488
|
+
for (const { entry, typeName } of shadowedRoots) {
|
|
4489
|
+
if (typeNames.includes(typeName)) {
|
|
4490
|
+
warnOnce(typesDict.types[typeName].gqltype, `transactionShadowed:${name}:${entry}`, 'Configuration issue: the '
|
|
4491
|
+
+ `registered mutation ${entry} replaces the generated mutation of ${typeName} with that name, so the ${name} `
|
|
4492
|
+
+ `field has no ${entry} operation.`);
|
|
4493
|
+
}
|
|
4494
|
+
}
|
|
4495
|
+
const operationEntries = new Map();
|
|
4496
|
+
const descriptors = [];
|
|
4497
|
+
for (const { key, field, marker } of collectOperationFields(mutation, typeNames)) {
|
|
4498
|
+
const forUpdate = marker.operation === operations.UPDATE || marker.operation === operations.STATE_CHANGED;
|
|
4499
|
+
const type = (forUpdate ? typesDictForUpdate : typesDict).types[marker.typeName];
|
|
4500
|
+
const entry = {
|
|
4501
|
+
type, operation: marker.operation, protected: isProtected(type.gqltype), rootField: field,
|
|
4502
|
+
};
|
|
4503
|
+
if (marker.operation === operations.STATE_CHANGED) {
|
|
4504
|
+
entry.actionName = marker.action;
|
|
4505
|
+
entry.actionField = type.stateMachine.actions[marker.action];
|
|
4506
|
+
}
|
|
4507
|
+
operationEntries.set(key, entry);
|
|
4508
|
+
const input = field.args.find((arg) => arg.name === 'input');
|
|
4509
|
+
descriptors.push({
|
|
4510
|
+
key,
|
|
4511
|
+
operation: marker.operation,
|
|
4512
|
+
protected: entry.protected,
|
|
4513
|
+
inputType: input ? unwrapNonNull(input.type) : null,
|
|
4514
|
+
returnType: field.type,
|
|
4515
|
+
});
|
|
4516
|
+
}
|
|
4517
|
+
assertTransactionSchema({
|
|
4518
|
+
name, mutation, operations: descriptors, typeExists: (typeName) => findSchemaType([query, mutation], typeName) !== null,
|
|
4519
|
+
});
|
|
4520
|
+
const state = {
|
|
4521
|
+
name, typeNames, operations: operationEntries, models: Object.freeze(transactionModels(operationEntries)),
|
|
4522
|
+
};
|
|
4523
|
+
const field = buildTransactionField({
|
|
4524
|
+
name,
|
|
4525
|
+
types,
|
|
4526
|
+
operations: descriptors,
|
|
4527
|
+
recordVersionRef: RecordVersionRef,
|
|
4528
|
+
resolveId: resolveTransactionResultId,
|
|
4529
|
+
// An empty list runs nothing: no transaction and no affinity check.
|
|
4530
|
+
async resolve(parent, args, context, info) {
|
|
4531
|
+
const { operations: list } = args;
|
|
4532
|
+
if (list.length === 0) return [];
|
|
4533
|
+
return executeTransaction(state, list, parent, context, info);
|
|
4534
|
+
},
|
|
4535
|
+
});
|
|
4536
|
+
const valueTypes = getNamedType(field.args.operations.type).getFields();
|
|
4537
|
+
for (const [key, entry] of operationEntries) entry.valueType = valueTypes[key].type;
|
|
4538
|
+
return { field, state };
|
|
3893
4539
|
};
|
|
3894
4540
|
|
|
3895
4541
|
const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations, options) => {
|
|
4542
|
+
Object.values(typesDict.types).forEach(({ controller }) => afterCommit.validate(controller));
|
|
3896
4543
|
// Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
|
|
3897
|
-
const { includedCustomQueries } = readSchemaOptions(options
|
|
4544
|
+
const { includedCustomQueries, transaction } = readSchemaOptions(options, includedMutationTypes,
|
|
4545
|
+
includedCustomMutations);
|
|
3898
4546
|
assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
|
|
3899
4547
|
assertViewsAvailable(includedQueryTypes, includedCustomQueries);
|
|
3900
4548
|
Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
|
|
@@ -3970,7 +4618,8 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3970
4618
|
applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
|
|
3971
4619
|
}
|
|
3972
4620
|
});
|
|
3973
|
-
const
|
|
4621
|
+
const shadowedRoots = [];
|
|
4622
|
+
let mutation = buildMutation('Mutation', includedMutationTypes, includedCustomMutations, shadowedRoots);
|
|
3974
4623
|
// An application scalar named JSON that this schema contains also types aggregation results. Any
|
|
3975
4624
|
// other type named JSON still collides, and GraphQLSchema reports the duplicate name.
|
|
3976
4625
|
const applicationJSON = findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult);
|
|
@@ -3978,8 +4627,22 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3978
4627
|
query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON),
|
|
3979
4628
|
includedCustomQueries);
|
|
3980
4629
|
}
|
|
4630
|
+
// The transaction field's checks that need the built roots run on them before it is added (#151).
|
|
4631
|
+
const built = transaction && buildTransaction(transaction, query, mutation, shadowedRoots);
|
|
4632
|
+
if (built) mutation = withTransactionField(mutation, transaction.name, built.field);
|
|
3981
4633
|
|
|
3982
4634
|
const schema = constructSchema(query, mutation, reserveVersionNames);
|
|
4635
|
+
if (built) recordTransactionState(built.field, built.state);
|
|
4636
|
+
captureSchemaRegistrations(schema, Object.values(typesDict.types).map((entry) => ({
|
|
4637
|
+
gqltype: entry.gqltype, endpoint: entry.endpoint,
|
|
4638
|
+
// Only associate roots generated for this schema. Excluded endpoint names can be custom queries.
|
|
4639
|
+
simpleEntityEndpointName: shouldNotBeIncludedInSchema(includedQueryTypes, entry.gqltype)
|
|
4640
|
+
? null : entry.simpleEntityEndpointName,
|
|
4641
|
+
inputType: entry.inputType, updateInputType: typesDictForUpdate.types[entry.gqltype.name]?.inputType,
|
|
4642
|
+
stateMachine: entry.stateMachine,
|
|
4643
|
+
})), (type, field) => field.extensions?.queryable !== false
|
|
4644
|
+
&& (field.extensions?.queryable === true || !hasApplicationResolver(type, field.name)),
|
|
4645
|
+
built ? schema.getMutationType().getFields()[transaction.name] : undefined);
|
|
3983
4646
|
// Reserve reachable types whose relation fields still have no resolver, such as an unregistered
|
|
3984
4647
|
// custom operation result, so another runtime cannot later generate resolvers this schema executes.
|
|
3985
4648
|
schemaTypes.forEach((type) => {
|
|
@@ -4335,6 +4998,7 @@ function formatArgs(argsArray) {
|
|
|
4335
4998
|
return {
|
|
4336
4999
|
configureQueryLimits,
|
|
4337
5000
|
configureMutationLimits,
|
|
5001
|
+
configureAfterCommit: afterCommit.configure,
|
|
4338
5002
|
connect,
|
|
4339
5003
|
addNoEndpointType,
|
|
4340
5004
|
createSchema,
|
|
@@ -4347,6 +5011,9 @@ function formatArgs(argsArray) {
|
|
|
4347
5011
|
registerCustomQuery,
|
|
4348
5012
|
registerView,
|
|
4349
5013
|
saveObject,
|
|
5014
|
+
updateObject,
|
|
5015
|
+
deleteObject,
|
|
5016
|
+
transitionObject,
|
|
4350
5017
|
preventCreatingCollection,
|
|
4351
5018
|
};
|
|
4352
5019
|
};
|