@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/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 { configureMutationLimits, getMaxNestedOperations } from './mutation-limits.js';
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, isScalarType,
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
- const thrown = isFieldErrorWrapper(error) ? thrownValueOf(error.originalError) : error;
449
- if (thrown instanceof GraphQLError && typeof thrown.extensions?.code !== 'string' && !simfinityCauseOf(thrown)) {
450
- 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();
451
497
  }
452
- return classifyValue(thrown);
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
- return new GraphQLInputObjectType({
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
- return adapter.withTransaction(session, (transaction) => onSaveObject(
1780
- type.model, type.gqltype, type.controller, cloneInput(snapshot, type.inputType), transaction, null, context,
1781
- ), 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);
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
- const attemptArgs = nextInput();
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
- 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 = []) => {
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
- 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 };
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 mutation = buildMutation('Mutation', includedMutationTypes, includedCustomMutations);
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
  };