@simtlix/simfinity-core 3.15.0 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  // Module-local ownership shared by consumers of this exact core installation. The snapshot is
2
2
  // registration data only; descriptor formats and HTTP belong to opt-in consumer packages.
3
3
  const registrationsBySchema = new WeakMap();
4
+ const transactionBySchema = new WeakMap();
4
5
 
5
- export const captureSchemaRegistrations = (schema, registrations, queryable) => {
6
+ export const captureSchemaRegistrations = (schema, registrations, queryable, transactionField) => {
6
7
  const snapshot = registrations.map((entry) => Object.freeze({
7
8
  ...entry,
8
9
  stateMachine: entry.stateMachine ? Object.freeze({
@@ -13,6 +14,10 @@ export const captureSchemaRegistrations = (schema, registrations, queryable) =>
13
14
  .map((field) => [field.name, queryable(entry.gqltype, field)]))),
14
15
  }));
15
16
  registrationsBySchema.set(schema, Object.freeze(snapshot));
17
+ if (transactionField) transactionBySchema.set(schema, transactionField);
16
18
  };
17
19
 
18
20
  export const schemaRegistrations = (schema) => registrationsBySchema.get(schema);
21
+
22
+ // Exact generated field identity, scoped to the schema that owns its registrations.
23
+ export const schemaTransaction = (schema) => transactionBySchema.get(schema);
@@ -0,0 +1,259 @@
1
+ import {
2
+ GraphQLID, GraphQLNonNull, GraphQLObjectType, getNamedType, isInputObjectType,
3
+ } from 'graphql';
4
+ import SimfinityError from './errors/simfinity.error.js';
5
+ import { getListShape, normalizeConnectionField } from './relation-storage.js';
6
+
7
+ // Internal, and never exported from the package. The references of the generated transaction (#151): an
8
+ // operation names the ID of an earlier operation's result with a `$K` token in one of four ID positions.
9
+ // planReferences() checks every token once, before the transaction, and resolveReferences() writes the
10
+ // IDs into each attempt's copy of the operations.
11
+ //
12
+ // The positions are found by walking each operation's value along its GraphQL input types, with the
13
+ // object type that generated them for the relation metadata (extensions.relation). Strings are never
14
+ // scanned: a value outside the four positions keeps its text, `$` included. The positions are:
15
+ // 1. the `id` of an update, action or delete operation;
16
+ // 2. the `id` of a single (non-embedded) reference field, an IdInputType;
17
+ // 3. the `id` of a nested `updated` entry and each nested `deleted` entry;
18
+ // 4. a non-list ID field of a child type that is the connectionField of collection fields, whose
19
+ // expected type is the type declaring those collections.
20
+
21
+ const SAVE = 'save';
22
+ const DELETE = 'delete';
23
+ // `$K`, with K = 0 or [1-9][0-9]*.
24
+ const TOKEN = /^\$(0|[1-9][0-9]*)$/;
25
+ // The expected type of a position that collections declared on two or more types claim (item 4).
26
+ const AMBIGUOUS = Symbol('ambiguous inverse reference');
27
+
28
+ const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
29
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
30
+ // `parent.name` for a field and `parent[n]` for a list item.
31
+ const childPath = (path, step) => (typeof step === 'number' ? `${path}[${step}]` : `${path}.${step}`);
32
+
33
+ // Names the operation and the path; the message never holds the value.
34
+ const referenceError = (operationIndex, operation, path, reason) => {
35
+ const error = new SimfinityError(`Invalid reference at ${path}: ${reason}`, 'INVALID_OPERATION_REFERENCE', 400);
36
+ error.extensions.operationIndex = operationIndex;
37
+ error.extensions.operation = operation;
38
+ return error;
39
+ };
40
+
41
+ // Item 4, for the types registered in a runtime: child type -> field name -> the types declaring a
42
+ // collection of that child whose connectionField (aliases included, through normalizeConnectionField)
43
+ // is that field. Only a non-list ID field counts. A collection without a connectionField, or whose
44
+ // connectionField names no field of the child, adds nothing.
45
+ const inverseReferences = (registry) => {
46
+ const index = new Map();
47
+ for (const declaring of registry.registeredTypes()) {
48
+ for (const field of Object.values(declaring.getFields())) {
49
+ const relation = field.extensions?.relation;
50
+ const listShape = getListShape(field.type);
51
+ if (!relation || relation.embedded || !listShape || !(listShape.itemType instanceof GraphQLObjectType)
52
+ || typeof relation.connectionField !== 'string' || relation.connectionField === '') continue;
53
+ const child = listShape.itemType;
54
+ const { graphqlFieldName } = normalizeConnectionField(child, relation.connectionField);
55
+ if (!graphqlFieldName || unwrapNonNull(child.getFields()[graphqlFieldName].type) !== GraphQLID) continue;
56
+ if (!index.has(child)) index.set(child, new Map());
57
+ const fields = index.get(child);
58
+ if (!fields.has(graphqlFieldName)) fields.set(graphqlFieldName, new Set());
59
+ fields.get(graphqlFieldName).add(declaring);
60
+ }
61
+ }
62
+ return index;
63
+ };
64
+
65
+ /**
66
+ * Plans the references of a transaction's operations.
67
+ *
68
+ * `operations` is the snapshot of the operation list, after the input walk: an array of `{ [key]: value }`
69
+ * items. `transactionState` is the state of the schema's transaction field (`operations`: key -> `{ type,
70
+ * operation, ... }`, where `type.gqltype` and `type.inputType` are the registered type and the input of
71
+ * the root). `registry` is `{ registeredTypes: () => GraphQLObjectType[] }`, the types registered in the
72
+ * runtime, over which the collections of item 4 are counted.
73
+ *
74
+ * Checks every token in an eligible position and throws INVALID_OPERATION_REFERENCE (400) for the first
75
+ * failure, in operation order, with `extensions.operationIndex` and `extensions.operation` and a message
76
+ * naming the path, such as `operations[2].updatepedido.items.updated[0].id`. The reasons are: malformed
77
+ * reference; operation K does not precede it; operation K does not exist; operation K is a delete;
78
+ * operation K writes <Type>, expected <Type>; ambiguous inverse reference. When every check passes, a
79
+ * value starting with `$$` loses one `$` in `operations`, in place (a failed plan leaves `operations` as
80
+ * it was), and the plan lists the tokens.
81
+ *
82
+ * Returns a frozen array of frozen positions `{ index, path, source, location }`: the operation holding
83
+ * the token, its path, K, and the steps from `operations[index]` to the token, for resolveReferences().
84
+ * The tokens stay in `operations`; each attempt's copy of it has them resolved.
85
+ */
86
+ export const planReferences = (operations, transactionState, registry) => {
87
+ const sourceKeys = operations.map((item) => Object.keys(item)[0]);
88
+ const positions = [];
89
+ const escapes = [];
90
+ let owners = null;
91
+ let current = null;
92
+
93
+ // The expected type of a non-list ID field of `child` (item 4): the single type declaring the
94
+ // collections that use it, AMBIGUOUS when two or more do, or undefined when none does.
95
+ const inverseOwner = (child, fieldName) => {
96
+ owners ??= inverseReferences(registry);
97
+ const declaring = owners.get(child)?.get(fieldName);
98
+ if (declaring === undefined) return undefined;
99
+ return declaring.size === 1 ? [...declaring][0] : AMBIGUOUS;
100
+ };
101
+
102
+ // `parent[step]`, found at `parentPath` and `parentLocation`, is a record key of an eligible position
103
+ // that expects a record of type `expected`.
104
+ const reference = (parent, step, parentPath, parentLocation, expected) => {
105
+ const value = parent[step];
106
+ if (typeof value !== 'string' || !value.startsWith('$')) return;
107
+ if (value.startsWith('$$')) {
108
+ escapes.push({ parent, step, literal: value.slice(1) });
109
+ return;
110
+ }
111
+ const path = childPath(parentPath, step);
112
+ const fail = (reason) => referenceError(current.index, current.key, path, reason);
113
+ const match = TOKEN.exec(value);
114
+ if (!match) throw fail('malformed reference');
115
+ const [, digits] = match;
116
+ const source = Number(digits);
117
+ if (source >= operations.length) throw fail(`operation ${digits} does not exist`);
118
+ if (source >= current.index) throw fail(`operation ${digits} does not precede it`);
119
+ const written = transactionState.operations.get(sourceKeys[source]);
120
+ if (written.operation === DELETE) throw fail(`operation ${digits} is a delete`);
121
+ if (expected === AMBIGUOUS) throw fail('ambiguous inverse reference');
122
+ if (written.type.gqltype !== expected) {
123
+ throw fail(`operation ${digits} writes ${written.type.gqltype.name}, expected ${expected.name}`);
124
+ }
125
+ positions.push(Object.freeze({
126
+ index: current.index, path, source, location: Object.freeze([...parentLocation, step]),
127
+ }));
128
+ };
129
+
130
+ // A record of `gqltype` as `inputType` writes it. Its `id` is a position when `idIsPosition`, which
131
+ // holds for the update input of an operation or of a nested `updated` entry.
132
+ const planObject = (value, inputType, gqltype, path, location, idIsPosition) => {
133
+ const inputFields = inputType.getFields();
134
+ const fields = gqltype.getFields();
135
+ for (const name of Object.keys(value)) {
136
+ if (value[name] === null || value[name] === undefined) continue;
137
+ if (name === 'id') {
138
+ if (idIsPosition) reference(value, name, path, location, gqltype);
139
+ } else if (Object.hasOwn(fields, name) && Object.hasOwn(inputFields, name)) {
140
+ planField(value, name, fields[name], inputFields[name], gqltype, path, location);
141
+ }
142
+ }
143
+ };
144
+
145
+ // `value[name]` against the field of `owner` that generated its input: a single reference, an embedded
146
+ // value, a referenced collection, or a non-list ID that is some collection's connectionField.
147
+ const planField = (value, name, field, inputField, owner, path, location) => {
148
+ const relation = field.extensions?.relation;
149
+ const listShape = getListShape(field.type);
150
+ const input = getNamedType(inputField.type);
151
+ const item = value[name];
152
+ const fieldPath = childPath(path, name);
153
+ const fieldLocation = [...location, name];
154
+ if (listShape) {
155
+ if (!(listShape.itemType instanceof GraphQLObjectType) || !isInputObjectType(input)) return;
156
+ if (!relation?.embedded) {
157
+ if (isObject(item)) planCollection(item, input, listShape.itemType, fieldPath, fieldLocation);
158
+ } else if (Array.isArray(item)) {
159
+ item.forEach((embedded, position) => {
160
+ if (!isObject(embedded)) return;
161
+ planObject(embedded, input, listShape.itemType, childPath(fieldPath, position), [...fieldLocation, position], false);
162
+ });
163
+ }
164
+ return;
165
+ }
166
+ const named = unwrapNonNull(field.type);
167
+ if (named instanceof GraphQLObjectType) {
168
+ if (!relation || !isObject(item) || !isInputObjectType(input)) return;
169
+ if (relation.embedded) planObject(item, input, named, fieldPath, fieldLocation, false);
170
+ else reference(item, 'id', fieldPath, fieldLocation, named);
171
+ } else if (named === GraphQLID && typeof item === 'string' && item.startsWith('$')) {
172
+ // Only a value that could be a token or an escape needs to know whether the field is claimed.
173
+ const expected = inverseOwner(owner, name);
174
+ if (expected !== undefined) reference(value, name, path, location, expected);
175
+ }
176
+ };
177
+
178
+ // The `added`, `updated` and `deleted` entries of a referenced collection's input, whose items are
179
+ // records of `itemType`. A null entry writes nothing.
180
+ const planCollection = (value, collectionInput, itemType, path, location) => {
181
+ const inputFields = collectionInput.getFields();
182
+ for (const key of ['added', 'updated']) {
183
+ const itemInput = Object.hasOwn(inputFields, key) ? getNamedType(inputFields[key].type) : undefined;
184
+ if (!isInputObjectType(itemInput) || !Array.isArray(value[key])) continue;
185
+ value[key].forEach((entry, position) => {
186
+ if (!isObject(entry)) return;
187
+ planObject(entry, itemInput, itemType, childPath(childPath(path, key), position),
188
+ [...location, key, position], key === 'updated');
189
+ });
190
+ }
191
+ if (!Object.hasOwn(inputFields, 'deleted') || !Array.isArray(value.deleted)) return;
192
+ // A protected item is deleted at a version: `{ id, version }`; otherwise the entry is the ID.
193
+ const versioned = isInputObjectType(getNamedType(inputFields.deleted.type));
194
+ const deletedPath = childPath(path, 'deleted');
195
+ const deletedLocation = [...location, 'deleted'];
196
+ value.deleted.forEach((entry, position) => {
197
+ if (!versioned) {
198
+ reference(value.deleted, position, deletedPath, deletedLocation, itemType);
199
+ } else if (isObject(entry)) {
200
+ reference(entry, 'id', childPath(deletedPath, position), [...deletedLocation, position], itemType);
201
+ }
202
+ });
203
+ };
204
+
205
+ operations.forEach((item, index) => {
206
+ const key = sourceKeys[index];
207
+ if (key === undefined) return;
208
+ const entry = transactionState.operations.get(key);
209
+ if (entry === undefined) throw new TypeError(`Unknown transaction operation ${key}`);
210
+ const value = item[key];
211
+ if (!isObject(value)) return;
212
+ current = { index, key };
213
+ const path = `operations[${index}].${key}`;
214
+ const { gqltype, inputType } = entry.type;
215
+ if (entry.operation === DELETE) reference(value, 'id', path, [key], gqltype);
216
+ else if (inputType) planObject(value, inputType, gqltype, path, [key], entry.operation !== SAVE);
217
+ });
218
+
219
+ for (const { parent, step, literal } of escapes) parent[step] = literal;
220
+ return Object.freeze(positions);
221
+ };
222
+
223
+ // The ID of a result as a client would send it back, serialized like the ID scalar: a string for a
224
+ // string, an integer or an object such as an ObjectId.
225
+ const serializeResultId = (result, source) => {
226
+ if (result === undefined || result === null || result.id === undefined || result.id === null) {
227
+ throw new Error(`The result of operation ${source} has no ID to reference`);
228
+ }
229
+ return GraphQLID.serialize(result.id);
230
+ };
231
+
232
+ /**
233
+ * Writes the IDs of results into the tokens of one attempt's copy of the operations, in place.
234
+ *
235
+ * `attemptOperations` is the attempt's copy of the planned snapshot, `referencePlan` what planReferences
236
+ * returned for it, and `attemptResults` an array indexed by operation index, filled as the attempt runs,
237
+ * whose entries are `{ id }`: the stored ID of that operation's record, as the adapter returned it
238
+ * (a string, an integer or an ObjectId). Each position gets its source's ID serialized like the ID scalar,
239
+ * overwriting what the position holds, so the IDs of a rolled-back attempt are never kept: the next
240
+ * attempt starts from a new copy and its own results.
241
+ *
242
+ * Without `operationIndex`, every position whose source already has a result is written and the others
243
+ * keep their tokens. With it, only the positions of that operation are written, and a missing result is
244
+ * an error; an attempt calls it with the index of the operation about to run, right before authorizing it.
245
+ */
246
+ export const resolveReferences = (attemptOperations, referencePlan, attemptResults, operationIndex = undefined) => {
247
+ for (const position of referencePlan) {
248
+ if (operationIndex !== undefined && position.index !== operationIndex) continue;
249
+ const result = attemptResults[position.source];
250
+ if (operationIndex === undefined && (result === undefined || result === null)) continue;
251
+ const id = serializeResultId(result, position.source);
252
+ let holder = attemptOperations[position.index];
253
+ for (const step of position.location.slice(0, -1)) holder = holder?.[step];
254
+ if (!isObject(holder) && !Array.isArray(holder)) {
255
+ throw new Error(`The copy of operation ${position.index} has no value at ${position.path}`);
256
+ }
257
+ holder[position.location.at(-1)] = id;
258
+ }
259
+ };
@@ -0,0 +1,163 @@
1
+ import {
2
+ GraphQLID, GraphQLInputObjectType, GraphQLList, GraphQLNonNull, GraphQLObjectType,
3
+ } from 'graphql';
4
+ import SimfinityError from './errors/simfinity.error.js';
5
+
6
+ // Internal, and never exported from the package. The schema side of the generated transaction field
7
+ // (#151): the shape of createSchema()'s `transaction` option, the operation keys of a built Mutation
8
+ // type, the checks once the generated mutations are built, and the field with its derived types.
9
+ // The runtime checks what needs its registrations and runs the transactions.
10
+
11
+ // The first sentence of the field's description, stable for SDL and introspection.
12
+ export const TRANSACTION_MARKER = 'Simfinity transaction: runs the listed generated mutations in order in one database transaction.';
13
+
14
+ const DEFAULT_NAME = 'transaction';
15
+ const OPTION_KEYS = new Set(['name', 'types']);
16
+ const GRAPHQL_NAME = /^[_A-Za-z][_0-9A-Za-z]*$/;
17
+ // The operations of the generated mutation roots a transaction runs; registered mutations, marked
18
+ // custom_mutation, never are operations.
19
+ const TRANSACTION_OPERATIONS = new Set(['save', 'update', 'delete', 'state_changed']);
20
+
21
+ export const invalidTransactionOption = (reason) => new SimfinityError(
22
+ `Invalid transaction option: ${reason}`, 'INVALID_SCHEMA_OPTIONS', 500,
23
+ );
24
+ export const transactionUnsupportedError = () => new SimfinityError(
25
+ 'The database adapter does not support generated transactions', 'TRANSACTION_UNSUPPORTED', 500,
26
+ );
27
+ export const noOperationError = () => invalidTransactionOption('none of its types has generated mutations in this schema');
28
+
29
+ const isPlainObject = (value) => value !== null && typeof value === 'object'
30
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
31
+
32
+ // The shape of the option: undefined or null adds no field; otherwise a plain object with known keys
33
+ // only, an optional `name` that is a GraphQL name not starting with __ (such a name builds, but every
34
+ // request then fails schema validation), and a non-empty array of distinct GraphQLObjectTypes. Returns
35
+ // null or a copy, `{ name, types }`, that later changes to the option do not reach. Whether the types
36
+ // are registered is the runtime's check.
37
+ export const readTransactionOption = (option) => {
38
+ if (option === undefined || option === null) return null;
39
+ if (!isPlainObject(option)) throw invalidTransactionOption('must be a plain object');
40
+ const unknown = Reflect.ownKeys(option).find((key) => !OPTION_KEYS.has(key));
41
+ if (unknown !== undefined) throw invalidTransactionOption(`unknown key ${String(unknown)}`);
42
+ const { name = DEFAULT_NAME, types } = option;
43
+ if (typeof name !== 'string' || !GRAPHQL_NAME.test(name) || name.startsWith('__')) {
44
+ throw invalidTransactionOption('name must be a GraphQL name that does not start with __');
45
+ }
46
+ const invalidTypes = () => invalidTransactionOption('types must be a non-empty array of GraphQLObjectType');
47
+ if (!Array.isArray(types) || types.length === 0) throw invalidTypes();
48
+ const copy = [];
49
+ for (let index = 0; index < types.length; index += 1) {
50
+ const type = types[index];
51
+ if (!Object.hasOwn(types, index) || !(type instanceof GraphQLObjectType)) throw invalidTypes();
52
+ if (copy.includes(type)) throw invalidTransactionOption(`types lists ${type.name} more than once`);
53
+ copy.push(type);
54
+ }
55
+ return { name, types: copy };
56
+ };
57
+
58
+ // The field's type names: the name with its first letter in upper case, followed by Operation,
59
+ // Result and DeleteInput.
60
+ export const transactionTypeNames = (name) => {
61
+ const base = name.charAt(0).toUpperCase() + name.slice(1);
62
+ return { operation: `${base}Operation`, result: `${base}Result`, deleteInput: `${base}DeleteInput` };
63
+ };
64
+
65
+ // The operation keys of a built Mutation type, in field order: the generated roots of these type names,
66
+ // each with its field and its extensions.simfinityMutation marker.
67
+ export const collectOperationFields = (mutation, typeNames) => Object.entries(mutation.getFields())
68
+ .filter(([, field]) => {
69
+ const marker = field.extensions?.simfinityMutation;
70
+ return !!marker && TRANSACTION_OPERATIONS.has(marker.operation) && typeNames.includes(marker.typeName);
71
+ })
72
+ .map(([key, field]) => ({ key, field, marker: field.extensions.simfinityMutation }));
73
+
74
+ // A delete of an unprotected type takes `{ id }`, which only the DeleteInput type holds.
75
+ const needsDeleteInput = (operations) => operations
76
+ .some(({ operation, protected: isProtected }) => operation === 'delete' && !isProtected);
77
+
78
+ // The names of the types the field adds to the schema.
79
+ const addedTypeNames = (name, operations) => {
80
+ const names = transactionTypeNames(name);
81
+ return [names.operation, names.result, ...(needsDeleteInput(operations) ? [names.deleteInput] : [])];
82
+ };
83
+
84
+ // The checks once the generated mutations are built, on the Mutation type without the field: the name
85
+ // against the roots built, at least one operation, and the added type names against every type the
86
+ // schema reaches. `typeExists(name)` answers whether the schema's roots reach a type of that name,
87
+ // input fields included. `operations` are the descriptors buildTransactionField takes.
88
+ export const assertTransactionSchema = ({
89
+ name, mutation, operations, typeExists,
90
+ }) => {
91
+ if (Object.hasOwn(mutation.getFields(), name)) {
92
+ throw invalidTransactionOption(`name ${name} conflicts with a mutation of this schema`);
93
+ }
94
+ if (operations.length === 0) throw noOperationError();
95
+ const taken = addedTypeNames(name, operations).find(typeExists);
96
+ if (taken) throw invalidTransactionOption(`the schema already has a type named ${taken}`);
97
+ };
98
+
99
+ // The field config: `operations` are `{ key, operation, protected, inputType, returnType }` in key
100
+ // order, where `inputType` is the root's named input (null for a delete) and `returnType` the root's
101
+ // type. Add, update and action values reuse the roots' inputs, which are nullable and have no defaults
102
+ // as @oneOf fields must; a delete takes `{ id }`, or `{ id, version }` (RecordVersionRef) for a
103
+ // protected type. The result has `id`, resolved by `resolveId`, and one nullable field per key, typed as
104
+ // that root.
105
+ export const buildTransactionField = ({
106
+ name, types, operations, recordVersionRef, resolve, resolveId,
107
+ }) => {
108
+ const names = transactionTypeNames(name);
109
+ const deleteInput = needsDeleteInput(operations)
110
+ ? new GraphQLInputObjectType({
111
+ name: names.deleteInput,
112
+ description: `The record a delete operation of ${name} removes.`,
113
+ fields: { id: { type: new GraphQLNonNull(GraphQLID) } },
114
+ })
115
+ : null;
116
+ const valueType = ({ operation, protected: isProtected, inputType }) => {
117
+ if (operation !== 'delete') return inputType;
118
+ return isProtected ? recordVersionRef : deleteInput;
119
+ };
120
+ const operationType = new GraphQLInputObjectType({
121
+ name: names.operation,
122
+ description: `One operation of ${name}: exactly one generated mutation, with its input.`,
123
+ isOneOf: true,
124
+ fields: Object.fromEntries(operations.map((operation) => [operation.key, { type: valueType(operation) }])),
125
+ });
126
+ const resultType = new GraphQLObjectType({
127
+ name: names.result,
128
+ description: `The result of one operation of ${name}: the record's id and the field of that operation.`,
129
+ fields: {
130
+ id: { type: GraphQLID, resolve: resolveId },
131
+ ...Object.fromEntries(operations.map(({ key, returnType }) => [key, { type: returnType }])),
132
+ },
133
+ });
134
+ return {
135
+ type: new GraphQLList(new GraphQLNonNull(resultType)),
136
+ description: TRANSACTION_MARKER,
137
+ args: { operations: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(operationType))) } },
138
+ extensions: { simfinityTransaction: { name, types: Object.freeze([...types]) } },
139
+ resolve,
140
+ };
141
+ };
142
+
143
+ // The Mutation type with the field added after its other fields.
144
+ export const withTransactionField = (mutation, name, field) => {
145
+ const config = mutation.toConfig();
146
+ return new GraphQLObjectType({ ...config, fields: { ...config.fields, [name]: field } });
147
+ };
148
+
149
+ // The state of each schema's field, keyed by its extensions.simfinityTransaction object, which every
150
+ // copy of the field shares, for instance after `new GraphQLSchema(schema.toConfig())`. The runtime
151
+ // records it once the schema exists, so a failed createSchema() leaves nothing.
152
+ const transactionStates = new WeakMap();
153
+
154
+ export const recordTransactionState = (field, state) => {
155
+ transactionStates.set(field.extensions.simfinityTransaction, state);
156
+ };
157
+
158
+ // The state of a field or field config, or undefined for any other field. No runtime code reads it: it is
159
+ // a hook for tests, which inspect the state the runtime recorded.
160
+ export const transactionStateOf = (field) => {
161
+ const marker = field?.extensions?.simfinityTransaction;
162
+ return marker !== null && typeof marker === 'object' ? transactionStates.get(marker) : undefined;
163
+ };