@simtlix/simfinity-core 3.4.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
15
15
  - `use`, `registerMutation`, and `saveObject` for middleware and write orchestration;
16
16
  - `preventCreatingCollection` to pass validation-only storage setup to an adapter.
17
17
 
18
- The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration.
18
+ The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize it.
19
19
 
20
20
  Generated and custom mutations run through `adapter.withTransaction`. Retried attempts receive fresh clones of GraphQL input objects, lists, and dates while enum values keep their identity. An adapter can define `stateValue` when its persisted enum representation differs from GraphQL enum names.
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.4.1",
3
+ "version": "3.5.0",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -36,5 +36,13 @@
36
36
  },
37
37
  "peerDependencies": {
38
38
  "graphql": "^16.11.0"
39
- }
39
+ },
40
+ "keywords": [
41
+ "simfinity",
42
+ "graphql",
43
+ "runtime",
44
+ "schema",
45
+ "crud",
46
+ "nodejs"
47
+ ]
40
48
  }
package/src/auth/index.js CHANGED
@@ -32,9 +32,11 @@
32
32
  * const yoga = createYoga({ schema, plugins: [authPlugin] });
33
33
  */
34
34
 
35
- import { GraphQLObjectType, defaultFieldResolver } from 'graphql';
35
+ import { GraphQLError, GraphQLObjectType, defaultFieldResolver } from 'graphql';
36
+ import SimfinityError from '../errors/simfinity.error.js';
36
37
  import { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
37
38
  import { isPolicyExpression, createRuleFromExpression, evaluateExpression } from './expressions.js';
39
+ import { collectQueryPaths, walkQueryPath } from '../query-plan.js';
38
40
  import {
39
41
  resolvePath,
40
42
  requireAuth,
@@ -195,6 +197,46 @@ const executeRule = async (rule, parent, args, ctx, info) => {
195
197
  return false;
196
198
  };
197
199
 
200
+ /**
201
+ * Applies read rules to the paths a client names in a generated list, aggregate or collection
202
+ * field's filter, sort and aggregation arguments. Each segment needs its own `Type.field` rule
203
+ * (exact, wildcard, then default policy). Rules run without a parent, with empty args and the
204
+ * path field's identity in info, so parent-dependent rules such as isOwner deny query paths. A rule that
205
+ * throws anything other than a Simfinity or GraphQL error denies the path.
206
+ */
207
+ const authorizeQueryPaths = async (permissions, defaultPolicy, schema, query, args, ctx, info) => {
208
+ const queriedType = query && schema?.getType?.(query.typeName);
209
+ if (!queriedType) return;
210
+ const checked = new Set();
211
+ for (const segments of collectQueryPaths(args, query.operation)) {
212
+ const steps = [];
213
+ walkQueryPath(queriedType, segments, (step) => steps.push(step));
214
+ for (const { type, fieldName, field } of steps) {
215
+ const key = `${type.name}.${fieldName}`;
216
+ if (checked.has(key)) continue;
217
+ checked.add(key);
218
+ const rules = getFieldRules(permissions, type.name, fieldName);
219
+ if (rules === null) {
220
+ if (defaultPolicy === 'DENY') throw new ForbiddenError(`Access denied to ${key}`);
221
+ continue;
222
+ }
223
+ // The operation and response location belong to the invoking query, but permissions must
224
+ // see the identity of the path field, just as when that field is selected directly.
225
+ const fieldInfo = { ...info, fieldName, parentType: type, returnType: field.type };
226
+ for (const rule of rules) {
227
+ let allowed;
228
+ try {
229
+ allowed = await executeRule(rule, undefined, {}, ctx, fieldInfo);
230
+ } catch (error) {
231
+ if (error instanceof SimfinityError || error instanceof GraphQLError) throw error;
232
+ allowed = false;
233
+ }
234
+ if (!allowed) throw new ForbiddenError(`Access denied to ${key}`);
235
+ }
236
+ }
237
+ }
238
+ };
239
+
198
240
  /**
199
241
  * Creates a graphql-middleware compatible authorization middleware.
200
242
  *
@@ -227,6 +269,7 @@ export const createAuthMiddleware = (permissions, options = {}) => {
227
269
 
228
270
  // Get rules for this field
229
271
  const rules = getFieldRules(permissions, typeName, fieldName);
272
+ const query = info.parentType?.getFields?.()[fieldName]?.extensions?.simfinityQuery;
230
273
 
231
274
  // If no rules found, apply default policy
232
275
  if (rules === null) {
@@ -237,6 +280,7 @@ export const createAuthMiddleware = (permissions, options = {}) => {
237
280
  }
238
281
 
239
282
  // ALLOW - proceed to resolver
283
+ await authorizeQueryPaths(permissions, defaultPolicy, info.schema, query, args, ctx, info);
240
284
  return resolve(parent, args, ctx, info);
241
285
  }
242
286
 
@@ -252,6 +296,7 @@ export const createAuthMiddleware = (permissions, options = {}) => {
252
296
  }
253
297
  }
254
298
 
299
+ await authorizeQueryPaths(permissions, defaultPolicy, info.schema, query, args, ctx, info);
255
300
  log(`Access granted to ${typeName}.${fieldName}`);
256
301
 
257
302
  // All rules passed - proceed to resolver
@@ -344,6 +389,7 @@ export const createAuthPlugin = (permissions, options = {}) => {
344
389
  for (const [fieldName, field] of Object.entries(fields)) {
345
390
  const rules = getFieldRules(permissions, typeName, fieldName);
346
391
  const originalResolve = field.resolve || defaultFieldResolver;
392
+ const query = field.extensions?.simfinityQuery;
347
393
 
348
394
  field.resolve = async (parent, args, ctx, info) => {
349
395
  log(`Checking ${typeName}.${fieldName}`);
@@ -355,6 +401,7 @@ export const createAuthPlugin = (permissions, options = {}) => {
355
401
  throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
356
402
  }
357
403
 
404
+ await authorizeQueryPaths(permissions, defaultPolicy, schema, query, args, ctx, info);
358
405
  return originalResolve(parent, args, ctx, info);
359
406
  }
360
407
 
@@ -369,6 +416,7 @@ export const createAuthPlugin = (permissions, options = {}) => {
369
416
  }
370
417
  }
371
418
 
419
+ await authorizeQueryPaths(permissions, defaultPolicy, schema, query, args, ctx, info);
372
420
  log(`Access granted to ${typeName}.${fieldName}`);
373
421
 
374
422
  return originalResolve(parent, args, ctx, info);
@@ -14,6 +14,8 @@ export const configureQueryLimits = (options = {}) => {
14
14
  queryMaxPageSize = maxPageSize;
15
15
  };
16
16
 
17
+ export const getQueryMaxPageSize = () => queryMaxPageSize;
18
+
17
19
  export const paginationStages = (pagination, withDefault) => {
18
20
  if (pagination == null) return withDefault ? [{ $skip: 0 }, { $limit: Math.min(100, queryMaxPageSize) }] : [];
19
21
  const { page, size } = pagination;
package/src/query-plan.js CHANGED
@@ -4,6 +4,60 @@ import SimfinityError from './errors/simfinity.error.js';
4
4
  const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
5
5
  const operators = new Set(['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE']);
6
6
  const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
7
+ const listOf = (value) => (Array.isArray(value) ? value : []);
8
+
9
+ /**
10
+ * Internal: the model paths named by generated query arguments, as `{ segments, group }` entries.
11
+ * Covers filter keys and relation terms, AND/OR conditions, list sort terms, and aggregation
12
+ * groupId and fact paths; aggregate sort terms name result keys. `group` is the AND/OR group
13
+ * object whose `conditions` hold the path, or null for the other forms. Malformed entries are
14
+ * left to the backends.
15
+ */
16
+ export const collectQueryPathEntries = (args, operation = 'find') => {
17
+ const entries = [];
18
+ const add = (path, group = null) => {
19
+ if (typeof path === 'string' && path) entries.push({ segments: path.split('.'), group });
20
+ };
21
+ if (!args || typeof args !== 'object') return entries;
22
+ const visit = (group) => {
23
+ if (!group || typeof group !== 'object') return;
24
+ for (const condition of listOf(group.conditions)) {
25
+ if (typeof condition?.field !== 'string') continue;
26
+ add(condition.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition.field, group);
27
+ }
28
+ for (const item of [...listOf(group.AND), ...listOf(group.OR)]) visit(item);
29
+ };
30
+ for (const [name, value] of Object.entries(args)) {
31
+ if (reserved.has(name) || value == null) continue;
32
+ const terms = listOf(value.terms).filter((term) => typeof term?.path === 'string' && term.path);
33
+ if (terms.length) for (const term of terms) add(`${name}.${term.path}`);
34
+ else add(name);
35
+ }
36
+ for (const item of [...listOf(args.AND), ...listOf(args.OR)]) visit(item);
37
+ if (operation === 'aggregate') {
38
+ add(args.aggregation?.groupId);
39
+ for (const fact of listOf(args.aggregation?.facts)) add(fact?.path);
40
+ } else for (const term of listOf(args.sort?.terms)) add(term?.field);
41
+ return entries;
42
+ };
43
+
44
+ /** Internal: the segment arrays of {@link collectQueryPathEntries}. */
45
+ export const collectQueryPaths = (args, operation = 'find') => (
46
+ collectQueryPathEntries(args, operation).map((entry) => entry.segments)
47
+ );
48
+
49
+ /** Internal: visits each declared GraphQL field a path names, stopping at an unknown or leaf segment. */
50
+ export const walkQueryPath = (gqltype, segments, visit) => {
51
+ let type = gqltype;
52
+ for (const [index, fieldName] of segments.entries()) {
53
+ const fields = typeof type?.getFields === 'function' ? type.getFields() : null;
54
+ if (!fields || !Object.hasOwn(fields, fieldName)) return;
55
+ const field = fields[fieldName];
56
+ visit({ type, fieldName, field, index });
57
+ type = field.type;
58
+ while (type?.ofType) type = type.ofType;
59
+ }
60
+ };
7
61
 
8
62
  export const resolveModelPath = (models, entityName, path) => {
9
63
  const parts = Array.isArray(path) ? path : typeof path === 'string' ? path.split('.') : [];
package/src/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { configureQueryLimits } from './query-limits.js';
1
+ import { configureQueryLimits, getQueryMaxPageSize } from './query-limits.js';
2
2
  import {
3
3
  GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
4
4
  GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
@@ -10,8 +10,66 @@ import InternalServerError from './errors/internal-server.error.js';
10
10
  import QLOperator from './const/QLOperator.js';
11
11
  import QLValue from './const/QLValue.js';
12
12
  import QLSort from './const/QLSort.js';
13
+ import { collectQueryPathEntries, collectQueryPaths, walkQueryPath } from './query-plan.js';
13
14
  import './introspection.js';
14
15
 
16
+ // Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
17
+ // created is application code, such as a masking resolver, recorded once per type so later
18
+ // in-place wrapping (for example by the auth plugin) does not change the snapshot.
19
+ const generatedResolvers = new WeakSet();
20
+ const applicationResolvedFields = new WeakMap();
21
+ const markGenerated = (resolve) => {
22
+ generatedResolvers.add(resolve);
23
+ return resolve;
24
+ };
25
+ const listApplicationResolvedFields = (gqltype) => new Set(Object.entries(gqltype.getFields())
26
+ .filter(([, field]) => field.resolve && !generatedResolvers.has(field.resolve))
27
+ .map(([fieldName]) => fieldName));
28
+ const hasApplicationResolver = (gqltype, fieldName) => (
29
+ applicationResolvedFields.get(gqltype) || listApplicationResolvedFields(gqltype)
30
+ ).has(fieldName);
31
+ const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER_PATH', 403);
32
+ const SCOPE_IGNORED_ARGS = new Set(['sort', 'pagination', 'aggregation']);
33
+
34
+ // Moves a joined type's scope group below the relation path that entered it.
35
+ const prefixScopeGroup = (group, prefix) => {
36
+ if (!group || typeof group !== 'object' || Array.isArray(group)) {
37
+ throw new SimfinityError('Expected a scope filter group object', 'INVALID_FILTER_VALUE', 400);
38
+ }
39
+ const prefixed = {};
40
+ if (group.conditions != null) {
41
+ prefixed.conditions = [].concat(group.conditions).map((condition) => ({
42
+ field: `${prefix}.${condition?.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition?.field}`,
43
+ operator: condition?.operator,
44
+ value: condition?.value,
45
+ }));
46
+ }
47
+ if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix));
48
+ if (group.OR != null) prefixed.OR = [].concat(group.OR).map((item) => prefixScopeGroup(item, prefix));
49
+ return prefixed;
50
+ };
51
+
52
+ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
53
+ const conditions = [];
54
+ const groups = [];
55
+ for (const [name, value] of Object.entries(scopeArgs)) {
56
+ if (value == null || SCOPE_IGNORED_ARGS.has(name)) continue;
57
+ if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix)));
58
+ else if (name === 'OR') {
59
+ const branches = [].concat(value);
60
+ if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix)) });
61
+ } else if (Object.hasOwn(value, 'terms')) {
62
+ if (!Array.isArray(value.terms) || !value.terms.length) {
63
+ throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
64
+ }
65
+ for (const term of value.terms) {
66
+ conditions.push({ field: `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value });
67
+ }
68
+ } else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value });
69
+ }
70
+ return conditions.length ? [{ conditions }, ...groups] : groups;
71
+ };
72
+
15
73
  const GraphQLJSON = new GraphQLScalarType({
16
74
  name: 'JSON',
17
75
  description: 'The `JSON` scalar type represents JSON values as specified by ECMA-404',
@@ -684,6 +742,42 @@ const getEmbeddedFieldNames = (gqltype) => {
684
742
  return names;
685
743
  };
686
744
 
745
+ // Update inputs relax embedded NonNull members, so check the embedded values an update writes
746
+ // against the embedded output type. Omitted lists are written as [], as on create. With `patch`,
747
+ // only nested embedded values supplied by that patch are checked; stored nested values it keeps
748
+ // are not. `id` and readOnly members are not accepted by generated inputs and remain the
749
+ // application's responsibility.
750
+ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
751
+ const listShape = getListShape(fieldEntry.type);
752
+ const embeddedType = listShape ? listShape.itemType : unwrapNonNull(fieldEntry.type);
753
+ let items = [value];
754
+ if (listShape) items = Array.isArray(value) ? value : [];
755
+ for (const item of items) {
756
+ if (item === null || item === undefined) {
757
+ if (listShape && listShape.itemNonNull) {
758
+ throw new SimfinityError(`Required value ${fieldName}[] is missing`, 'REQUIRED_VALUE', 400);
759
+ }
760
+ continue;
761
+ }
762
+ for (const [memberName, member] of Object.entries(embeddedType.getFields())) {
763
+ const relation = member.extensions?.relation;
764
+ const memberList = getListShape(member.type);
765
+ if (relation && !relation.embedded && memberList) continue;
766
+ const storageName = getFieldStorageName(memberName, member);
767
+ if (item[storageName] === undefined && memberList) item[storageName] = [];
768
+ if (memberName === 'id' || member.extensions?.readOnly) continue;
769
+ const memberValue = item[storageName];
770
+ if (memberValue === null || memberValue === undefined) {
771
+ if (member.type instanceof GraphQLNonNull) {
772
+ throw new SimfinityError(`Required value ${memberName} is missing`, 'REQUIRED_VALUE', 400);
773
+ }
774
+ } else if (relation && relation.embedded && (!patch || Object.hasOwn(patch, storageName))) {
775
+ completeEmbeddedValue(memberName, member, memberValue);
776
+ }
777
+ }
778
+ }
779
+ };
780
+
687
781
  const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
688
782
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
689
783
  const objectId = args.id;
@@ -704,8 +798,11 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
704
798
  if (newObjectData) {
705
799
  if (Array.isArray(newObjectData)) {
706
800
  materializedModel.modelArgs[fieldEntryName] = newObjectData;
801
+ completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName], newObjectData);
707
802
  } else {
708
803
  materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
804
+ completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName],
805
+ materializedModel.modelArgs[fieldEntryName], newObjectData);
709
806
  }
710
807
  }
711
808
  }
@@ -921,9 +1018,203 @@ const executeScope = async (params) => {
921
1018
  return scopeFunction({ type, args, operation, context });
922
1019
  };
923
1020
 
1021
+ // Copies AND/OR group lists with new group objects and arrays, leaving conditions and other values
1022
+ // shared.
1023
+ const copyFilterGroups = (groups) => (Array.isArray(groups) ? groups.map((group) => {
1024
+ if (!group || typeof group !== 'object' || Array.isArray(group)) return group;
1025
+ const copy = { ...group };
1026
+ if (Array.isArray(group.conditions)) copy.conditions = [...group.conditions];
1027
+ if (Object.hasOwn(group, 'AND')) copy.AND = copyFilterGroups(group.AND);
1028
+ if (Object.hasOwn(group, 'OR')) copy.OR = copyFilterGroups(group.OR);
1029
+ return copy;
1030
+ }) : groups);
1031
+
1032
+ // Checks the filter, sort and aggregation paths a client supplied, before middleware and scopes
1033
+ // add trusted paths. Returns the scoped relations those paths enter, keyed by path, with the
1034
+ // client AND/OR groups whose conditions use each path, or `topLevel` for every other form.
1035
+ // graphql-js passes the same variable values to every resolver call of a request, so the client's
1036
+ // AND/OR groups are first copied onto this call's args, and joined scopes are only placed in those copies.
1037
+ const inspectClientPaths = (gqltype, args, operation) => {
1038
+ for (const key of ['AND', 'OR']) {
1039
+ if (Object.hasOwn(args, key)) args[key] = copyFilterGroups(args[key]);
1040
+ }
1041
+ const joinedScopes = new Map();
1042
+ for (const { segments, group } of collectQueryPathEntries(args, operation)) {
1043
+ walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
1044
+ const path = segments.slice(0, index + 1).join('.');
1045
+ const queryable = field.extensions?.queryable;
1046
+ if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
1047
+ throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
1048
+ }
1049
+ const relation = field.extensions?.relation;
1050
+ if (!relation || relation.embedded) return;
1051
+ const target = typesDict.types[unwrapListAndNonNull(field.type).name];
1052
+ if (typeof target?.gqltype?.extensions?.scope?.find !== 'function') return;
1053
+ if (!joinedScopes.has(path)) {
1054
+ joinedScopes.set(path, {
1055
+ target,
1056
+ collection: getListShape(field.type) ? `${type.name}.${fieldName}` : null,
1057
+ topLevel: false,
1058
+ groups: new Set(),
1059
+ });
1060
+ }
1061
+ const joined = joinedScopes.get(path);
1062
+ if (group) joined.groups.add(group);
1063
+ else joined.topLevel = true;
1064
+ });
1065
+ }
1066
+ return joinedScopes;
1067
+ };
1068
+
1069
+ // A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
1070
+ // reach a non-embedded relation inside an embedded list, which is joined once per list item.
1071
+ const assertJoinedScopePaths = (groups, prefix, target) => {
1072
+ const depth = prefix.split('.').length;
1073
+ for (const segments of collectQueryPaths({ AND: groups })) {
1074
+ let inList = false;
1075
+ walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field }) => {
1076
+ const relation = field.extensions?.relation;
1077
+ const list = !!getListShape(field.type);
1078
+ if (relation && !relation.embedded && (list || inList)) {
1079
+ throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope filters through `
1080
+ + `${list ? 'the collection' : 'the relation'} ${type.name}.${fieldName}${list ? '' : ' inside a list'}`);
1081
+ }
1082
+ inList ||= list;
1083
+ });
1084
+ }
1085
+ };
1086
+
1087
+ const appendAndGroups = (owner, groups) => {
1088
+ if (owner.AND != null && !Array.isArray(owner.AND)) {
1089
+ throw new SimfinityError('AND requires an array', 'INVALID_FILTER_VALUE', 400);
1090
+ }
1091
+ owner.AND = [...(owner.AND || []), ...groups];
1092
+ };
1093
+
1094
+ // The deepest AND/OR group nesting the backends accept; groups listed in args.AND/OR are at depth 0.
1095
+ const MAX_FILTER_GROUP_DEPTH = 5;
1096
+ const nestedGroups = (group) => [...[].concat(group.AND ?? []), ...[].concat(group.OR ?? [])];
1097
+ const groupDepth = (group) => Math.max(0, ...nestedGroups(group)
1098
+ .filter((item) => item && typeof item === 'object').map((item) => groupDepth(item) + 1));
1099
+
1100
+ // Maps each AND/OR group of the query to the deepest level it appears at.
1101
+ const collectFilterGroups = (args) => {
1102
+ const found = new Map();
1103
+ const visit = (group, depth) => {
1104
+ if (!group || typeof group !== 'object' || found.get(group) >= depth) return;
1105
+ found.set(group, depth);
1106
+ if (depth <= MAX_FILTER_GROUP_DEPTH) for (const item of nestedGroups(group)) visit(item, depth + 1);
1107
+ };
1108
+ for (const item of nestedGroups(args)) visit(item, 0);
1109
+ return found;
1110
+ };
1111
+
1112
+ // Restricts referenced records reached by client paths with each target type's find scope. A path
1113
+ // used only in AND/OR group conditions restricts just those groups, so an OR alternative that does
1114
+ // not use it keeps its matches; any other use, a group that middleware or a scope detached, or a
1115
+ // group too deep to hold the scope groups within the backends' depth limit, restricts the whole query.
1116
+ const applyJoinedScopes = async (joinedScopes, params) => {
1117
+ let queryGroups;
1118
+ for (const [prefix, {
1119
+ target, collection, topLevel, groups: owners,
1120
+ }] of joinedScopes) {
1121
+ const scopeArgs = {};
1122
+ await executeScope({ type: target, args: scopeArgs, operation: 'find', context: params.context });
1123
+ const groups = prefixScopeArgs(scopeArgs, prefix, target.gqltype.name);
1124
+ if (!groups.length) continue;
1125
+ if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
1126
+ assertJoinedScopePaths(groups, prefix, target);
1127
+ queryGroups ??= collectFilterGroups(params.args);
1128
+ const addedDepth = 1 + Math.max(...groups.map(groupDepth));
1129
+ if (topLevel || [...owners].some((owner) => !queryGroups.has(owner)
1130
+ || queryGroups.get(owner) + addedDepth > MAX_FILTER_GROUP_DEPTH)) appendAndGroups(params.args, groups);
1131
+ else for (const owner of owners) appendAndGroups(owner, groups);
1132
+ }
1133
+ };
1134
+
1135
+ // Unscoped relation reads of one request, grouped by related type until the promise jobs queued so
1136
+ // far have run, and keyed by cast ID. Nothing is cached: each batch is read once and dropped.
1137
+ const referenceBatches = new WeakMap();
1138
+
1139
+ const readReferences = async (type, keys, batch, context) => {
1140
+ let found = new Map();
1141
+ try {
1142
+ const records = await adapter.getByIds(type.model, keys.map((key) => batch.get(key)[0].id), { context });
1143
+ for (const record of records) {
1144
+ if (record != null) found.set(String(adapter.castId(record._id ?? record.id)), record);
1145
+ }
1146
+ } catch {
1147
+ found = null;
1148
+ }
1149
+ // After a failed batch, each ID is read alone, so an error fails only the fields that use it.
1150
+ const reads = new Map();
1151
+ for (const key of keys) {
1152
+ for (const { id, requiredId, resolve, reject } of batch.get(key)) {
1153
+ if (found) {
1154
+ resolve(found.get(key) ?? null);
1155
+ continue;
1156
+ }
1157
+ if (!reads.has(id)) {
1158
+ reads.set(id, Promise.resolve().then(() => adapter.getById(type.model, id, null, { requiredId, context })));
1159
+ }
1160
+ reads.get(id).then(resolve, reject);
1161
+ }
1162
+ }
1163
+ };
1164
+
1165
+ const flushReferences = (type, batch, context) => {
1166
+ const keys = [...batch.keys()];
1167
+ const size = getQueryMaxPageSize();
1168
+ for (let start = 0; start < keys.length; start += size) {
1169
+ readReferences(type, keys.slice(start, start + size), batch, context);
1170
+ }
1171
+ };
1172
+
1173
+ const loadReference = (type, id, requiredId, context) => {
1174
+ let key;
1175
+ try {
1176
+ key = String(adapter.castId(id));
1177
+ } catch {
1178
+ // An ID the adapter cannot cast is read alone, so only its field reports the error.
1179
+ return adapter.getById(type.model, id, null, { requiredId, context });
1180
+ }
1181
+ // Casting may normalize (e.g. ObjectId hex case), so non-canonical IDs are read alone too.
1182
+ if (key !== String(id)) return adapter.getById(type.model, id, null, { requiredId, context });
1183
+ return new Promise((resolve, reject) => {
1184
+ let batches = referenceBatches.get(context);
1185
+ if (!batches) {
1186
+ batches = new Map();
1187
+ referenceBatches.set(context, batches);
1188
+ }
1189
+ let batch = batches.get(type);
1190
+ if (!batch) {
1191
+ batch = new Map();
1192
+ batches.set(type, batch);
1193
+ // As DataLoader does, flush once the promise jobs queued so far have run, so sibling calls
1194
+ // that awaited middleware without I/O join the same batch.
1195
+ Promise.resolve().then(() => process.nextTick(() => {
1196
+ batches.delete(type);
1197
+ flushReferences(type, batch, context);
1198
+ }));
1199
+ }
1200
+ if (!batch.has(key)) batch.set(key, []);
1201
+ batch.get(key).push({
1202
+ id, requiredId, resolve, reject,
1203
+ });
1204
+ });
1205
+ };
1206
+
1207
+ // `requiredId` marks a generated single-reference read. Middleware still runs per call; then, when the
1208
+ // adapter reads by ID in batches, and unless the type has a get_by_id scope or middleware changed the
1209
+ // ID, the read joins the request's batch.
924
1210
  const resolveById = async (type, args, context, requiredId) => {
1211
+ const requestedId = args.id;
925
1212
  await executeMiddleware({ type, args, operation: 'get_by_id', context });
926
1213
  if (!type.gqltype.extensions?.scope?.get_by_id) {
1214
+ if (requiredId != null && args.id === requestedId && typeof context === 'object' && context !== null
1215
+ && typeof adapter.getByIds === 'function' && type.gqltype.getFields().id) {
1216
+ return loadReference(type, args.id, requiredId, context);
1217
+ }
927
1218
  return adapter.getById(type.model, args.id, null, { requiredId, context });
928
1219
  }
929
1220
  const queryArgs = { id: { operator: 'EQ', value: args.id } };
@@ -1069,7 +1360,7 @@ const buildRootQuery = (name, includedTypes) => {
1069
1360
  const wasAddedAsNoEnpointType = !type.simpleEntityEndpointName;
1070
1361
  if (!wasAddedAsNoEnpointType) {
1071
1362
  if (type.gqltype.getFields().id && !type.gqltype.getFields().id.resolve) {
1072
- type.gqltype.getFields().id.resolve = (parent) => parent._id;
1363
+ type.gqltype.getFields().id.resolve = markGenerated((parent) => parent._id);
1073
1364
  }
1074
1365
 
1075
1366
  rootQueryArgs.fields[type.simpleEntityEndpointName] = {
@@ -1087,7 +1378,9 @@ const buildRootQuery = (name, includedTypes) => {
1087
1378
  rootQueryArgs.fields[type.listEntitiesEndpointName] = {
1088
1379
  type: new GraphQLList(type.gqltype),
1089
1380
  args: argsObject,
1381
+ extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find' } },
1090
1382
  async resolve(parent, args, context) {
1383
+ const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
1091
1384
  const params = {
1092
1385
  type,
1093
1386
  args,
@@ -1096,11 +1389,13 @@ const buildRootQuery = (name, includedTypes) => {
1096
1389
  };
1097
1390
  await executeMiddleware(params);
1098
1391
  await executeScope(params);
1099
- const wantsCount = !!(args.pagination && args.pagination.count);
1392
+ await applyJoinedScopes(joinedScopes, params);
1393
+ const queryArgs = params.args;
1394
+ const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count);
1100
1395
 
1101
- const dataPromise = adapter.find(type.model, type.gqltype, args, null);
1396
+ const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
1102
1397
  const countPromise = wantsCount
1103
- ? adapter.count(type.model, type.gqltype, args, null)
1398
+ ? adapter.count(type.model, type.gqltype, queryArgs, null)
1104
1399
  : null;
1105
1400
 
1106
1401
  const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
@@ -1119,7 +1414,9 @@ const buildRootQuery = (name, includedTypes) => {
1119
1414
  rootQueryArgs.fields[`${type.listEntitiesEndpointName}_aggregate`] = {
1120
1415
  type: new GraphQLList(QLTypeAggregationResult),
1121
1416
  args: aggregateArgsObject,
1417
+ extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
1122
1418
  async resolve(parent, args, context) {
1419
+ const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
1123
1420
  const params = {
1124
1421
  type,
1125
1422
  args,
@@ -1128,7 +1425,8 @@ const buildRootQuery = (name, includedTypes) => {
1128
1425
  };
1129
1426
  await executeMiddleware(params);
1130
1427
  await executeScope(params);
1131
- return adapter.aggregate(type.model, type.gqltype, args, null);
1428
+ await applyJoinedScopes(joinedScopes, params);
1429
+ return adapter.aggregate(type.model, type.gqltype, params.args, null);
1132
1430
  },
1133
1431
  };
1134
1432
  }
@@ -1185,8 +1483,15 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1185
1483
  }
1186
1484
  });
1187
1485
 
1486
+ const query = buildRootQuery('RootQueryType', includedQueryTypes);
1487
+ Object.values(typesDict.types).forEach(({ gqltype }) => {
1488
+ if (gqltype && !applicationResolvedFields.has(gqltype)) {
1489
+ applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
1490
+ }
1491
+ });
1492
+
1188
1493
  return new GraphQLSchema({
1189
- query: buildRootQuery('RootQueryType', includedQueryTypes),
1494
+ query,
1190
1495
  mutation: buildMutation('Mutation', includedMutationTypes, includedCustomMutations),
1191
1496
  });
1192
1497
  };
@@ -1232,28 +1537,34 @@ const autoGenerateResolvers = (gqltype) => {
1232
1537
  if (connection.graphqlFieldName) delete argsObject[connection.graphqlFieldName];
1233
1538
 
1234
1539
  fieldEntry.args = formatArgs(Object.entries(argsObject));
1235
- fieldEntry.resolve = async (parent, args, context) => {
1540
+ fieldEntry.extensions = {
1541
+ ...fieldEntry.extensions,
1542
+ simfinityQuery: { typeName: relatedType.name, operation: 'find' },
1543
+ };
1544
+ fieldEntry.resolve = markGenerated(async (parent, args, context) => {
1236
1545
  if (!relatedTypeInfo || !relatedTypeInfo.model) {
1237
1546
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
1238
1547
  }
1548
+ const joinedScopes = inspectClientPaths(relatedType, args, 'find');
1239
1549
  const params = { type: relatedTypeInfo, args, operation: 'find', context };
1240
1550
  await executeMiddleware(params);
1241
1551
  await executeScope(params);
1552
+ await applyJoinedScopes(joinedScopes, params);
1242
1553
  return adapter.findChildren(
1243
1554
  relatedTypeInfo.model,
1244
1555
  relatedTypeInfo.gqltype,
1245
1556
  connection.storageFieldName,
1246
1557
  parent.id || parent._id,
1247
- args,
1558
+ params.args,
1248
1559
  null,
1249
1560
  );
1250
- };
1561
+ });
1251
1562
  } else if (fieldEntry.type instanceof GraphQLObjectType
1252
1563
  || (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType)) {
1253
1564
  const relatedType = unwrapNonNull(fieldEntry.type);
1254
1565
  const connectionField = relation.connectionField || fieldName;
1255
1566
 
1256
- fieldEntry.resolve = async (parent, args, context) => {
1567
+ fieldEntry.resolve = markGenerated(async (parent, args, context) => {
1257
1568
  const relatedTypeInfo = typesDict.types[relatedType.name];
1258
1569
  if (!relatedTypeInfo || !relatedTypeInfo.model) {
1259
1570
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
@@ -1261,7 +1572,7 @@ const autoGenerateResolvers = (gqltype) => {
1261
1572
  const relatedId = parent[connectionField] || parent[fieldName];
1262
1573
  const id = relatedId?._id || relatedId;
1263
1574
  return id ? resolveById(relatedTypeInfo, { id: String(id) }, context, id) : null;
1264
- };
1575
+ });
1265
1576
  }
1266
1577
  }
1267
1578
  };
@@ -0,0 +1,7 @@
1
+ // Accepts exactly the strings matched by /^[^\s@]+@[^\s@]+\.[^\s@]+$/ in linear time. The
2
+ // lookahead rejects a domain containing '@' or whitespace before the ambiguous `x.y` split is
3
+ // tried, so hostile input such as 'a@' + '.'.repeat(n) + ' ' cannot backtrack quadratically.
4
+ const EMAIL_RE = /^[^\s@]+@(?=[^\s@]*$)[^\s@]+\.[^\s@]+$/;
5
+
6
+ /** Email shape check shared by `validators.email()` and `EmailScalar`; not a published subpath. */
7
+ export const isEmailFormat = (value) => EMAIL_RE.test(value);
package/src/scalars.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  GraphQLString, GraphQLInt, GraphQLFloat,
3
3
  } from 'graphql';
4
4
  import { createValidatedScalar } from './scalars/factory.js';
5
+ import { isEmailFormat } from './scalars/email.js';
5
6
 
6
7
  /**
7
8
  * Email scalar - validates email format
@@ -12,8 +13,7 @@ export const EmailScalar = createValidatedScalar(
12
13
  'A valid email address',
13
14
  GraphQLString,
14
15
  (value) => {
15
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
16
- if (!emailRegex.test(value)) {
16
+ if (!isEmailFormat(value)) {
17
17
  throw new Error('Invalid email format');
18
18
  }
19
19
  },
package/src/validators.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import SimfinityError from './errors/simfinity.error.js';
2
+ import { isEmailFormat } from './scalars/email.js';
2
3
 
3
4
  /**
4
5
  * Creates a validation object that works for both 'save' (CREATE) and 'update' (UPDATE) operations.
@@ -88,14 +89,12 @@ export const pattern = (name, regex, message) => {
88
89
  };
89
90
 
90
91
  export const email = () => {
91
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
92
-
93
92
  return createValidator(async (typeName, fieldName, value) => {
94
93
  if (typeof value !== 'string') {
95
94
  throw new SimfinityError('Email must be a string', 'VALIDATION_ERROR', 400);
96
95
  }
97
96
 
98
- if (!emailRegex.test(value)) {
97
+ if (!isEmailFormat(value)) {
99
98
  throw new SimfinityError('Invalid email format', 'VALIDATION_ERROR', 400);
100
99
  }
101
100
  }, false); // Optional
package/types/index.d.ts CHANGED
@@ -126,6 +126,8 @@ export interface DatabaseAdapter<Model = any, Session = any> {
126
126
  saveRecord(model: Model, record: any, session?: Session): any;
127
127
  toObject(record: any): any;
128
128
  getById(model: Model, id: any, session?: Session | null, options?: { projection?: Record<string, number>; plain?: boolean; lock?: boolean; requiredId?: any; context?: any }): any;
129
+ /** Optional batch read: the records found for `ids`, in getById shape and any order. After a failure, the runtime reads each ID with getById. */
130
+ getByIds?(model: Model, ids: any[], options?: { context?: any }): any;
129
131
  prepareUpdate(set: Record<string, any>, unset: Record<string, string>): any;
130
132
  update(model: Model, id: any, changes: any, session?: Session): any;
131
133
  delete(model: Model, id: any, session?: Session): any;