@simtlix/simfinity-core 3.5.5 → 3.5.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.5.5",
3
+ "version": "3.5.7",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/runtime.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
5
5
  GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
6
6
  GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
7
- Kind, defaultFieldResolver, getNamedType,
7
+ Kind, getNamedType,
8
8
  } from 'graphql';
9
9
 
10
10
  import SimfinityError from './errors/simfinity.error.js';
@@ -24,6 +24,26 @@ import './introspection.js';
24
24
  // created is application code, such as a masking resolver, recorded once per type so later
25
25
  // in-place wrapping (for example by the auth plugin) does not change the snapshot.
26
26
  const generatedResolvers = new WeakSet();
27
+ // A caller that runs one operation per call, such as simfinity-mcp, can collect the pagination
28
+ // count through the root value instead of the shared context: a function stored under this key
29
+ // receives the count. The key is not exported; simfinity-mcp defines the same Symbol.for key.
30
+ const COUNT_SINK = Symbol.for('simfinity.countSink');
31
+ const countSinkOf = (value) => {
32
+ if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return undefined;
33
+ const sink = value[COUNT_SINK];
34
+ return typeof sink === 'function' ? sink : undefined;
35
+ };
36
+ // The root value arrives as parent, which resolver wrappers that forward only (parent, args,
37
+ // context) still pass on; info.rootValue is the fallback. Without a sink the count goes to
38
+ // context.count, where Envelop/Apollo/Yoga count plugins read it.
39
+ const reportCount = (parent, info, context, count) => {
40
+ const sink = countSinkOf(parent) || (info ? countSinkOf(info.rootValue) : undefined);
41
+ if (sink) {
42
+ sink(count);
43
+ return;
44
+ }
45
+ context.count = count;
46
+ };
27
47
  const applicationResolvedFields = new WeakMap();
28
48
  const markGenerated = (resolve) => {
29
49
  generatedResolvers.add(resolve);
@@ -264,6 +284,29 @@ export const buildErrorFormatter = (callback) => (error) => {
264
284
 
265
285
  export { SimfinityError, InternalServerError };
266
286
 
287
+ // A field named like an Object.prototype member (`constructor`, `toString`, ...) that reads as that
288
+ // very member holds no value: graphql input objects and stored records without the field inherit
289
+ // it, and MongoDB nested paths return it from their own accessors. Other values, overriding members
290
+ // such as class methods and getters, and every other name read as usual. The property is read once,
291
+ // so a getter runs once, as with a plain read.
292
+ const isObjectMember = (key, read) => key in Object.prototype && read === Object.prototype[key];
293
+ const ownValue = (value, key) => {
294
+ if (value == null) return undefined;
295
+ const read = value[key];
296
+ return isObjectMember(key, read) ? undefined : read;
297
+ };
298
+
299
+ // Output fields with such names read through this resolver instead of graphql's default one, which
300
+ // would call the inherited member. Otherwise it resolves as the default one does, calling a method
301
+ // with the parent as receiver, but reads the property only once. It reads no data, so installing it
302
+ // binds no type to a runtime.
303
+ const readOwnField = markGenerated((parent, args, context, info) => {
304
+ if (parent === null || (typeof parent !== 'object' && typeof parent !== 'function')) return undefined;
305
+ const property = parent[info.fieldName];
306
+ if (isObjectMember(info.fieldName, property)) return null;
307
+ return typeof property === 'function' ? property.call(parent, args, context, info) : property;
308
+ });
309
+
267
310
  const cloneInput = (value, type) => {
268
311
  if (value === null || value === undefined || !type) return value;
269
312
  if (type instanceof GraphQLNonNull) return cloneInput(value, type.ofType);
@@ -791,28 +834,29 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
791
834
  const collectionFields = {};
792
835
 
793
836
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
837
+ const fieldValue = ownValue(args, fieldEntryName);
794
838
  if (fieldEntry.extensions && fieldEntry.extensions.validations
795
839
  && fieldEntry.extensions.validations[operation]) {
796
840
  for (const validator of fieldEntry.extensions.validations[operation]) {
797
- await validator.validate(gqltype.name, fieldEntryName, args[fieldEntryName], session);
841
+ await validator.validate(gqltype.name, fieldEntryName, fieldValue, session);
798
842
  }
799
843
  }
800
844
 
801
- if (args[fieldEntryName] !== undefined && args[fieldEntryName] !== null) {
845
+ if (fieldValue !== undefined && fieldValue !== null) {
802
846
  if (fieldEntry.type instanceof GraphQLScalarType
803
847
  || fieldEntry.type instanceof GraphQLEnumType
804
848
  || isNonNullOfType(fieldEntry.type, GraphQLScalarType)
805
849
  || isNonNullOfType(fieldEntry.type, GraphQLEnumType)) {
806
- modelArgs[fieldEntryName] = args[fieldEntryName];
850
+ modelArgs[fieldEntryName] = fieldValue;
807
851
  } else if (fieldEntry.type instanceof GraphQLObjectType
808
852
  || isNonNullOfType(fieldEntry.type, GraphQLObjectType)) {
809
853
  if (fieldEntry.extensions && fieldEntry.extensions.relation) {
810
854
  if (!fieldEntry.extensions.relation.embedded) {
811
- modelArgs[getFieldStorageName(fieldEntryName, fieldEntry)] = adapter.castId(args[fieldEntryName].id);
855
+ modelArgs[getFieldStorageName(fieldEntryName, fieldEntry)] = adapter.castId(fieldValue.id);
812
856
  } else {
813
857
  const fieldType = fieldEntry.type instanceof GraphQLNonNull
814
858
  ? fieldEntry.type.ofType : fieldEntry.type;
815
- modelArgs[fieldEntryName] = (await materializeModel(args[fieldEntryName], fieldType,
859
+ modelArgs[fieldEntryName] = (await materializeModel(fieldValue, fieldType,
816
860
  null, operation, session)).modelArgs;
817
861
  }
818
862
  } else {
@@ -827,11 +871,11 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
827
871
  if (itemType instanceof GraphQLObjectType && fieldEntry.extensions
828
872
  && fieldEntry.extensions.relation) {
829
873
  if (!fieldEntry.extensions.relation.embedded) {
830
- collectionFields[fieldEntryName] = args[fieldEntryName];
874
+ collectionFields[fieldEntryName] = fieldValue;
831
875
  } else if (fieldEntry.extensions.relation.embedded) {
832
876
  const collectionEntries = [];
833
877
 
834
- for (const element of args[fieldEntryName]) {
878
+ for (const element of fieldValue) {
835
879
  if (element === null) {
836
880
  collectionEntries.push(null);
837
881
  } else {
@@ -843,9 +887,15 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
843
887
  modelArgs[fieldEntryName] = collectionEntries;
844
888
  }
845
889
  } else if (itemType instanceof GraphQLScalarType || itemType instanceof GraphQLEnumType) {
846
- modelArgs[fieldEntryName] = args[fieldEntryName];
890
+ modelArgs[fieldEntryName] = fieldValue;
847
891
  }
848
892
  }
893
+ } else if (operation === 'CREATE' && fieldEntryName in Object.prototype && getListShape(fieldEntry.type)
894
+ && !(fieldEntry.extensions?.relation && !fieldEntry.extensions.relation.embedded)) {
895
+ // A create without a value stores [] for a list: SQL backends store it for every list, and
896
+ // Mongoose defaults to it, except for a list named like an Object.prototype member, where it
897
+ // finds the inherited member. Collections are not stored on the record.
898
+ modelArgs[fieldEntryName] = [];
849
899
  }
850
900
  }
851
901
 
@@ -931,9 +981,9 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
931
981
  const memberList = getListShape(member.type);
932
982
  if (relation && !relation.embedded && memberList) continue;
933
983
  const storageName = getFieldStorageName(memberName, member);
934
- if (item[storageName] === undefined && memberList) item[storageName] = [];
984
+ if (ownValue(item, storageName) === undefined && memberList) item[storageName] = [];
935
985
  if (memberName === 'id' || member.extensions?.readOnly) continue;
936
- const memberValue = item[storageName];
986
+ const memberValue = ownValue(item, storageName);
937
987
  if (memberValue === null || memberValue === undefined) {
938
988
  if (member.type instanceof GraphQLNonNull) {
939
989
  throw new SimfinityError(`Required value ${memberName} is missing`, 'REQUIRED_VALUE', 400);
@@ -979,8 +1029,8 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
979
1029
  });
980
1030
  if (currentObject) {
981
1031
  for (const fieldEntryName of embeddedFieldNames) {
982
- const oldObjectData = currentObject[fieldEntryName];
983
- const newObjectData = materializedModel.modelArgs[fieldEntryName];
1032
+ const oldObjectData = ownValue(currentObject, fieldEntryName);
1033
+ const newObjectData = ownValue(materializedModel.modelArgs, fieldEntryName);
984
1034
  if (newObjectData) {
985
1035
  if (Array.isArray(newObjectData)) {
986
1036
  materializedModel.modelArgs[fieldEntryName] = newObjectData;
@@ -988,7 +1038,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
988
1038
  } else {
989
1039
  materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
990
1040
  clearNullEmbeddedMembers(materializedModel.modelArgs[fieldEntryName],
991
- argTypes[fieldEntryName], args[fieldEntryName]);
1041
+ argTypes[fieldEntryName], ownValue(args, fieldEntryName));
992
1042
  completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName],
993
1043
  materializedModel.modelArgs[fieldEntryName], newObjectData);
994
1044
  }
@@ -1478,6 +1528,9 @@ const resolveById = async (type, args, context, requiredId) => {
1478
1528
  return results[0] || null;
1479
1529
  };
1480
1530
 
1531
+ // Every generated mutation field carries extensions.simfinityMutation with its operation (and, for
1532
+ // a state-machine action, the action and its from/to states), so tools such as the MCP package can
1533
+ // classify it without guessing from its name. Extensions do not appear in SDL or introspection.
1481
1534
  const buildMutation = (name, includedMutationTypes, includedCustomMutations) => {
1482
1535
  const rootQueryArgs = {};
1483
1536
  rootQueryArgs.name = name;
@@ -1494,6 +1547,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1494
1547
  type: type.gqltype,
1495
1548
  description: 'add',
1496
1549
  args: argsObject,
1550
+ extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.SAVE } },
1497
1551
  async resolve(parent, args, context) {
1498
1552
  const params = {
1499
1553
  type,
@@ -1511,6 +1565,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1511
1565
  type: type.gqltype,
1512
1566
  description: 'delete',
1513
1567
  args: { id: { type: new GraphQLNonNull(GraphQLID) } },
1568
+ extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.DELETE } },
1514
1569
  async resolve(parent, args, context) {
1515
1570
  const params = {
1516
1571
  type,
@@ -1536,6 +1591,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1536
1591
  type: type.gqltype,
1537
1592
  description: 'update',
1538
1593
  args: argsObject,
1594
+ extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.UPDATE } },
1539
1595
  async resolve(parent, args, context) {
1540
1596
  const params = {
1541
1597
  type,
@@ -1556,6 +1612,15 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1556
1612
  type: type.gqltype,
1557
1613
  description: actionField.description,
1558
1614
  args: argsObject,
1615
+ extensions: {
1616
+ simfinityMutation: {
1617
+ typeName: type.gqltype.name,
1618
+ operation: operations.STATE_CHANGED,
1619
+ action: actionName,
1620
+ from: actionField.from && actionField.from.name,
1621
+ to: actionField.to && actionField.to.name,
1622
+ },
1623
+ },
1559
1624
  async resolve(parent, args, context) {
1560
1625
  const params = {
1561
1626
  type,
@@ -1587,6 +1652,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1587
1652
  type: registeredMutation.outputModel,
1588
1653
  description: registeredMutation.description,
1589
1654
  args: argsObject,
1655
+ extensions: { simfinityMutation: { operation: operations.CUSTOM_MUTATION } },
1590
1656
  async resolve(parent, args, context) {
1591
1657
  const params = {
1592
1658
  args,
@@ -1627,8 +1693,10 @@ const buildRootQuery = (name, includedTypes) => {
1627
1693
  rootQueryArgs.fields[type.listEntitiesEndpointName] = {
1628
1694
  type: new GraphQLList(type.gqltype),
1629
1695
  args: argsObject,
1630
- extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find' } },
1631
- async resolve(parent, args, context) {
1696
+ // countSink: this resolver reports pagination counts through reportCount, so a caller
1697
+ // that supplies a root-value sink can pass its context unchanged.
1698
+ extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
1699
+ async resolve(parent, args, context, info) {
1632
1700
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
1633
1701
  const params = {
1634
1702
  type,
@@ -1649,7 +1717,7 @@ const buildRootQuery = (name, includedTypes) => {
1649
1717
 
1650
1718
  const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
1651
1719
  if (wantsCount) {
1652
- context.count = resultCount;
1720
+ reportCount(parent, info, context, resultCount);
1653
1721
  }
1654
1722
  return result;
1655
1723
  },
@@ -1724,8 +1792,9 @@ const installIdResolver = (gqltype) => {
1724
1792
  // this runtime: another runtime that reaches a shared type reads the same value through it.
1725
1793
  // The value is read as graphql's default resolver reads it, so a method on the parent, such as one a
1726
1794
  // custom mutation result supplies, is still called; a promise it returns is read once it settles.
1795
+ // Only a field named like an Object.prototype member that reads as that member reads as null.
1727
1796
  const readEmbeddedField = markGenerated((parent, args, context, info) => {
1728
- const value = defaultFieldResolver(parent, args, context, info);
1797
+ const value = readOwnField(parent, args, context, info);
1729
1798
  return isThenable(value)
1730
1799
  ? value.then((resolved) => adapter.readEmbeddedValue(resolved))
1731
1800
  : adapter.readEmbeddedValue(value);
@@ -1744,6 +1813,20 @@ const installEmbeddedValueResolvers = (gqltype, visited) => {
1744
1813
  }
1745
1814
  };
1746
1815
 
1816
+ // Fields named like Object.prototype members, of registered types and the embedded types they reach,
1817
+ // read through readOwnField when they have no resolver yet; references and collections keep the ones
1818
+ // autoGenerateResolvers gives them.
1819
+ const installObjectMemberResolvers = (gqltype, visited) => {
1820
+ if (!(gqltype instanceof GraphQLObjectType) || visited.has(gqltype)) return;
1821
+ visited.add(gqltype);
1822
+ for (const [fieldName, fieldEntry] of Object.entries(gqltype.getFields())) {
1823
+ const relation = fieldEntry.extensions?.relation;
1824
+ if (relation && !relation.embedded) continue;
1825
+ if (fieldName in Object.prototype && !fieldEntry.resolve) fieldEntry.resolve = readOwnField;
1826
+ if (relation) installObjectMemberResolvers(unwrapListAndNonNull(fieldEntry.type), visited);
1827
+ }
1828
+ };
1829
+
1747
1830
  // Configuration issues this runtime already reported, per type, so a repeated createSchema call or a
1748
1831
  // type reached from several places logs each one once.
1749
1832
  const reportedIssues = new WeakMap();
@@ -1827,6 +1910,9 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1827
1910
  }
1828
1911
  }
1829
1912
  });
1913
+ // After every type has its relation and embedded value resolvers, which these fields keep.
1914
+ const objectMemberTypes = new Set();
1915
+ Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
1830
1916
 
1831
1917
  const query = buildRootQuery('RootQueryType', includedQueryTypes);
1832
1918
  Object.values(typesDict.types).forEach(({ gqltype }) => {
@@ -1939,8 +2025,15 @@ const autoGenerateResolvers = (gqltype) => {
1939
2025
  if (!relatedTypeInfo || !relatedTypeInfo.model) {
1940
2026
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
1941
2027
  }
1942
- // Any stored identifier except null or an empty string, so numeric IDs such as 0 resolve.
1943
- const relatedId = parent[connectionField] ?? parent[fieldName];
2028
+ // Any stored identifier except null or an empty string, so numeric IDs such as 0 resolve. A
2029
+ // function under the field name is a member, such as the class a MongoDB document inherits
2030
+ // `constructor` from, not an identifier.
2031
+ // The field name is read only when the stored identifier is missing.
2032
+ let relatedId = ownValue(parent, connectionField);
2033
+ if (relatedId == null) {
2034
+ const linked = ownValue(parent, fieldName);
2035
+ relatedId = typeof linked === 'function' ? undefined : linked;
2036
+ }
1944
2037
  const id = relatedId?._id ?? relatedId;
1945
2038
  if (id == null || id === '') return null;
1946
2039
  try {
@@ -3,6 +3,11 @@ import { GraphQLScalarType, GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBool
3
3
  // The base each validated scalar delegates parsing to. A base exists before its scalar, so these links cannot cycle.
4
4
  const validatedBases = new WeakMap();
5
5
 
6
+ // The same link as a non-enumerable property, so MCP (packages/mcp/src/index.js, VALIDATED_SCALAR_BASE) can tell a
7
+ // validated scalar, whose output is its base's, from a hand-set `baseScalarType` storage hint, whose own `serialize`
8
+ // decides the output. Not exported; Symbol.for gives both packages the same key.
9
+ const VALIDATED_SCALAR_BASE = Symbol.for('simfinity.validatedScalarBase');
10
+
6
11
  // Follow validated-scalar chains to the scalar that defines the literal syntax. Any other scalar is the root, even
7
12
  // with a hand-set `baseScalarType` storage hint, because its own parseLiteral decides which literals it takes.
8
13
  const findRootScalar = (scalarType) => {
@@ -68,5 +73,6 @@ export function createValidatedScalar(name, description, baseScalarType, validat
68
73
 
69
74
  scalar.baseScalarType = baseScalarType;
70
75
  validatedBases.set(scalar, baseScalarType);
76
+ Object.defineProperty(scalar, VALIDATED_SCALAR_BASE, { value: baseScalarType });
71
77
  return scalar;
72
78
  }
package/types/index.d.ts CHANGED
@@ -66,9 +66,12 @@ export class SimfinityError extends Error {
66
66
  getCode(): string | undefined;
67
67
  getStatus(): number | undefined;
68
68
  getTimestamp(): string;
69
- /** Set on errors that wrap a driver error, such as MongoDB's TRANSACTION_RETRY_EXCEEDED. */
69
+ /**
70
+ * Set on errors that wrap a driver error, such as TRANSACTION_RETRY_EXCEEDED, and on the PostgreSQL database errors
71
+ * that a SQL runtime maps or reports as DATABASE_ERROR; on those PostgreSQL errors it is not enumerable.
72
+ */
70
73
  cause?: unknown;
71
- /** Set on errors that wrap a driver error, such as MongoDB's TRANSACTION_RETRY_EXCEEDED; returns `cause`. */
74
+ /** Set on errors that wrap a driver error, such as TRANSACTION_RETRY_EXCEEDED, and on PostgreSQL database errors; returns `cause`. */
72
75
  getCause?(): unknown;
73
76
  }
74
77