@simtlix/simfinity-core 3.15.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/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 { configureMutationLimits, getMaxNestedOperations } from './mutation-limits.js';
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, isScalarType,
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';
@@ -27,7 +31,8 @@ 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
@@ -430,14 +445,21 @@ const classifyValue = (value) => {
430
445
  : new InternalServerError('Unexpected error value');
431
446
  };
432
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
+
433
459
  // Errors raised while executing a field have a path; request errors (syntax, validation, variable
434
460
  // coercion) never do. GraphQL servers pass the GraphQLError that graphql-js created around the value
435
461
  // a resolver threw, and that value is classified, without unwrapping a GraphQLError the resolver
436
462
  // 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
463
  const classifyError = (error) => {
442
464
  if (!(error instanceof GraphQLError)) return classifyValue(error);
443
465
  if (!Array.isArray(error.path)) {
@@ -447,11 +469,33 @@ const classifyError = (error) => {
447
469
  const cause = simfinityCauseOf(error);
448
470
  return cause instanceof InternalServerError ? cause : classifyValue(error);
449
471
  }
450
- const thrown = isFieldErrorWrapper(error) ? thrownValueOf(error.originalError) : error;
451
- if (thrown instanceof GraphQLError && typeof thrown.extensions?.code !== 'string' && !simfinityCauseOf(thrown)) {
452
- return new InternalServerError(thrown.message, thrown);
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();
453
497
  }
454
- return classifyValue(thrown);
498
+ return decorated;
455
499
  };
456
500
 
457
501
  export const buildErrorFormatter = (callback) => (error) => {
@@ -564,12 +608,19 @@ export const createRuntime = (adapter) => {
564
608
 
565
609
  const stateValue = adapter.stateValue || ((state) => state.name);
566
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 = {};
567
614
  const typesDict = { types: {} };
568
615
  const waitingInputType = {};
569
616
  const typesDictForUpdate = { types: {} };
570
617
  // `added` item inputs of referenced collections by generated name, so collections that reach the
571
618
  // same child through the same connectionField share one input type.
572
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();
573
624
  // Self-referencing collection field name -> the types whose collection inputs were built with the
574
625
  // unqualified names OneToManyA<field> and OneToManyU<field>; type -> its self-referencing
575
626
  // collections whose inputs are named after the type instead (see claimSelfCollectionNames).
@@ -906,7 +957,7 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
906
957
  + `${itemType.name} records with a registered mutation.`,
907
958
  };
908
959
 
909
- return new GraphQLInputObjectType({
960
+ const collectionInput = new GraphQLInputObjectType({
910
961
  name,
911
962
  fields: () => {
912
963
  const fields = {};
@@ -929,6 +980,8 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
929
980
  return fields;
930
981
  },
931
982
  });
983
+ collectionInputTypes.add(collectionInput);
984
+ return collectionInput;
932
985
  };
933
986
 
934
987
  // What graphQLListInputType returns for a list of an embedded type whose input has no fields.
@@ -1779,17 +1832,144 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
1779
1832
  return result;
1780
1833
  };
1781
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
+
1782
1861
  const saveObject = async (typeName, args, session, context) => {
1783
1862
  const type = typesDict.types[typeName];
1784
1863
  assertCollectionWrites(type.gqltype, args);
1785
1864
  const snapshot = cloneInput(args, type.inputType);
1786
- return adapter.withTransaction(session, async (transaction) => {
1787
- const attemptArgs = cloneInput(snapshot, type.inputType);
1788
- const record = await onSaveObject(type.model, type.gqltype, type.controller, attemptArgs, transaction, null, context);
1789
- afterCommit.queue({ typeName, controller: type.controller, operation: operations.SAVE,
1790
- record, args: attemptArgs, context, session: transaction });
1791
- return record;
1792
- }, type.model);
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);
1793
1973
  };
1794
1974
 
1795
1975
  // Counts the added, updated and deleted entries of referenced collections, at every nesting level that
@@ -1805,10 +1985,7 @@ const assertNestedOperations = (gqltype, input, limit, state = { count: 0 }) =>
1805
1985
  for (const key of ['added', 'updated', 'deleted']) {
1806
1986
  if (!Array.isArray(value[key])) continue;
1807
1987
  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
- }
1988
+ if (state.count > limit) throw nestedOperationsExceededError(limit);
1812
1989
  if (key !== 'deleted') {
1813
1990
  for (const item of value[key]) assertNestedOperations(listShape.itemType, item, limit, state);
1814
1991
  }
@@ -1867,6 +2044,44 @@ const preReadTopLevel = async (Model, gqltype, { id, version }, advances, contex
1867
2044
  return { expectedVersion, observedVersion, record };
1868
2045
  };
1869
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
+
1870
2085
  // A delete passes the ID, or { id, version } for a protected type, which each attempt copies; every
1871
2086
  // other operation passes its input, which attemptInputs copies. Either way, a retry checks the version
1872
2087
  // the request sent.
@@ -1881,39 +2096,8 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
1881
2096
  else if (versioned) nextInput = () => ({ ...args });
1882
2097
  return adapter.withTransaction(
1883
2098
  session,
1884
- async (mySession) => {
1885
- const attemptArgs = nextInput();
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
- },
2099
+ async (mySession) => runOperation({ Model, gqltype, controller }, operation, nextInput(), mySession, context,
2100
+ actionField, actionName),
1917
2101
  Model,
1918
2102
  );
1919
2103
  };
@@ -1942,7 +2126,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1942
2126
  // A protected child is updated or deleted at the version its entry carries (#98). A deleted entry is
1943
2127
  // { id, version } for item middleware; one that is not an object, such as a string ID, has no version.
1944
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);
1945
2132
  for (const element of collectionFieldsList) {
2133
+ if (executionState !== undefined) countNestedEntry(executionState);
1946
2134
  if (element === null) continue;
1947
2135
  let args = { input: element };
1948
2136
  if (operationType === operations.DELETE) {
@@ -3059,8 +3247,9 @@ const buildRegisteredOperationField = (entry, registration, operation) => {
3059
3247
  // classify it without guessing from its name. Extensions do not appear in SDL or introspection.
3060
3248
  // An input object needs a field, so a type whose create input has none gets no add mutation, and
3061
3249
  // 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
- const buildMutation = (name, includedMutationTypes, includedCustomMutations) => {
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 = []) => {
3064
3253
  const rootQueryArgs = {};
3065
3254
  rootQueryArgs.name = name;
3066
3255
  rootQueryArgs.fields = {};
@@ -3194,6 +3383,8 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
3194
3383
 
3195
3384
  for (const [entry, registeredMutation] of Object.entries(registeredMutations)) {
3196
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 });
3197
3388
  rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredMutation,
3198
3389
  operations.CUSTOM_MUTATION);
3199
3390
  }
@@ -3896,11 +4087,46 @@ const invalidSchemaOptions = (detail) => new SimfinityError(
3896
4087
  `createSchema options ${detail}`, 'INVALID_SCHEMA_OPTIONS', 500,
3897
4088
  );
3898
4089
 
3899
- 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
+ };
3900
4126
 
3901
4127
  // The fourth createSchema() argument: omitted, or a plain object with known keys only.
3902
- const readSchemaOptions = (options) => {
3903
- if (options === undefined) return {};
4128
+ const readSchemaOptions = (options, includedMutationTypes, includedCustomMutations) => {
4129
+ if (options === undefined) return { transaction: null };
3904
4130
  if (options === null || typeof options !== 'object' || !isPlainObject(options)) {
3905
4131
  throw invalidSchemaOptions('must be a plain object');
3906
4132
  }
@@ -3913,13 +4139,410 @@ const readSchemaOptions = (options) => {
3913
4139
  || includedCustomQueries.some((name) => typeof name !== 'string'))) {
3914
4140
  throw invalidSchemaOptions('includedCustomQueries must be an array of strings, null or undefined');
3915
4141
  }
3916
- return { includedCustomQueries };
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 };
3917
4539
  };
3918
4540
 
3919
4541
  const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations, options) => {
3920
4542
  Object.values(typesDict.types).forEach(({ controller }) => afterCommit.validate(controller));
3921
4543
  // Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
3922
- const { includedCustomQueries } = readSchemaOptions(options);
4544
+ const { includedCustomQueries, transaction } = readSchemaOptions(options, includedMutationTypes,
4545
+ includedCustomMutations);
3923
4546
  assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
3924
4547
  assertViewsAvailable(includedQueryTypes, includedCustomQueries);
3925
4548
  Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
@@ -3995,7 +4618,8 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3995
4618
  applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
3996
4619
  }
3997
4620
  });
3998
- const mutation = buildMutation('Mutation', includedMutationTypes, includedCustomMutations);
4621
+ const shadowedRoots = [];
4622
+ let mutation = buildMutation('Mutation', includedMutationTypes, includedCustomMutations, shadowedRoots);
3999
4623
  // An application scalar named JSON that this schema contains also types aggregation results. Any
4000
4624
  // other type named JSON still collides, and GraphQLSchema reports the duplicate name.
4001
4625
  const applicationJSON = findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult);
@@ -4003,8 +4627,12 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
4003
4627
  query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON),
4004
4628
  includedCustomQueries);
4005
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);
4006
4633
 
4007
4634
  const schema = constructSchema(query, mutation, reserveVersionNames);
4635
+ if (built) recordTransactionState(built.field, built.state);
4008
4636
  captureSchemaRegistrations(schema, Object.values(typesDict.types).map((entry) => ({
4009
4637
  gqltype: entry.gqltype, endpoint: entry.endpoint,
4010
4638
  // Only associate roots generated for this schema. Excluded endpoint names can be custom queries.
@@ -4013,7 +4641,8 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
4013
4641
  inputType: entry.inputType, updateInputType: typesDictForUpdate.types[entry.gqltype.name]?.inputType,
4014
4642
  stateMachine: entry.stateMachine,
4015
4643
  })), (type, field) => field.extensions?.queryable !== false
4016
- && (field.extensions?.queryable === true || !hasApplicationResolver(type, field.name)));
4644
+ && (field.extensions?.queryable === true || !hasApplicationResolver(type, field.name)),
4645
+ built ? schema.getMutationType().getFields()[transaction.name] : undefined);
4017
4646
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
4018
4647
  // custom operation result, so another runtime cannot later generate resolvers this schema executes.
4019
4648
  schemaTypes.forEach((type) => {
@@ -4382,6 +5011,9 @@ function formatArgs(argsArray) {
4382
5011
  registerCustomQuery,
4383
5012
  registerView,
4384
5013
  saveObject,
5014
+ updateObject,
5015
+ deleteObject,
5016
+ transitionObject,
4385
5017
  preventCreatingCollection,
4386
5018
  };
4387
5019
  };