@simtlix/simfinity-core 3.5.2 → 3.5.5

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
@@ -8,14 +8,21 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
8
8
 
9
9
  ## Runtime
10
10
 
11
- `createRuntime(adapter)` creates an isolated Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom mutations, and state machines. It exposes:
11
+ `createRuntime(adapter)` creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom mutations, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
12
12
 
13
13
  - `connect`, `addNoEndpointType`, and `createSchema` for registration and schema creation;
14
14
  - `getModel`, `getType`, `getInputType`, and `getRegistrations` for runtime inspection;
15
15
  - `use`, `registerMutation`, and `saveObject` for middleware and write orchestration;
16
- - `preventCreatingCollection` to pass validation-only storage setup to an adapter.
16
+ - `preventCreatingCollection` to pass validation-only storage setup to an adapter;
17
+ - `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size and nested-operation limits.
17
18
 
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
+ 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 or reject it. `castId` should throw a `NOT_VALID_ID` (400) `SimfinityError` for a malformed value and must not create a new identifier. When `getById` rejects a stored reference value that way, including one that `castId` rejected first, the generated reference field fails with an `InternalServerError`, since the client did not send the value. An adapter that hydrates records can define `readEmbeddedValue(value)`: every nullable, singular embedded object field without a resolver then passes its value through it, so an explicitly stored `null` reads as `null`. The field is read as graphql's default resolver reads it, so a method on the parent is called and a promise it returns is awaited before the hook receives the value. The MongoDB adapter defines it.
20
+
21
+ Entity types, those registered with `connect()` and the targets of non-embedded relations, resolve `id` to `_id`, falling back to `id`, whatever the `createSchema()` allowlists include; types used only as embedded values keep their declared `id`. Embedded copies of an entity type resolve the same way, so they return their declared `id` unless the adapter stores an `_id` for them, as MongoDB does for embedded list items. The resolver is set on the type object, so it applies in every runtime that shares the type. `createSchema()` marks these registrations with `storedIdentity: true` before calling `adapter.prepare()`.
22
+
23
+ When a runtime first creates a schema, it binds each GraphQL object type whose non-embedded relation fields receive its generated resolvers. It also reserves each reachable object type that still has a non-embedded relation field without a resolver, such as an unregistered custom mutation result. Registering a bound or reserved type in another runtime, or building another runtime's schema that reaches it through fields, interfaces, union members or custom mutation results, throws `TYPE_BOUND_TO_OTHER_RUNTIME` (409). `createSchema()` also rejects a type with a relation field copied, by `toConfig()` or a field spread, after another runtime generated that field's resolver. Create type objects once per runtime, for example with a factory function. Types without non-embedded relation fields, and types whose relation fields all have application resolvers, can be shared, and another runtime can use a `toConfig()` copy made before the first runtime built its schema. A shared custom mutation result type with an unresolved relation field is rejected.
24
+
25
+ Registration and `createSchema()` validate `extensions.scope` (`INVALID_SCOPE`) and reject list or embedded relations to unregistered types (`UNREGISTERED_RELATION_TARGET`); `use()` rejects non-functions (`INVALID_MIDDLEWARE`). Before `adapter.prepare()`, `createSchema()` rejects a writable non-embedded list relation without a non-empty child `connectionField` whose resolver it would generate (`INVALID_MODEL`, 400). A collection without `connectionField` that has an application resolver, or is `readOnly`, only logs a `Configuration issue` warning, unless it is both, and nested writes through it fail with `INVALID_MODEL` (500) before anything is written; adapters that describe the whole model, such as PostgreSQL and transactional MongoDB, still reject it in `prepare()`. Fields named like generated query arguments (`sort`, `pagination`, `AND`, `OR`, and `aggregation` on aggregate endpoints) also log a warning once per runtime.
19
26
 
20
27
  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
28
 
@@ -27,4 +34,4 @@ Importing the runtime installs Simfinity's `__Field.extensions` introspection fi
27
34
 
28
35
  `createQueryPlan` and `resolveModelPath` turn Simfinity filter, sort, pagination, and aggregation inputs into a driver-neutral plan resolved against that metadata.
29
36
 
30
- The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `buildErrorFormatter`, `QLOperator`, `QLSort`, and `QLValue`. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
37
+ The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `buildErrorFormatter`, `configureQueryLimits`, `configureMutationLimits`, `QLOperator`, `QLSort`, and `QLValue`. `buildErrorFormatter` returns a `GraphQLError` whose `originalError` is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.5.2",
3
+ "version": "3.5.5",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -15,6 +15,7 @@
15
15
  "./auth/expressions": "./src/auth/expressions.js",
16
16
  "./auth/rules": "./src/auth/rules.js",
17
17
  "./internal/object-id": "./src/auth/object-id.js",
18
+ "./internal/relation-storage": "./src/relation-storage.js",
18
19
  "./plugins": "./src/plugins.js",
19
20
  "./scalars": "./src/scalars.js",
20
21
  "./validators": "./src/validators.js"
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { normalizeObjectId } from './object-id.js';
8
+ import { isThenable, discardThenable } from './thenable.js';
8
9
 
9
10
  const invalidResult = Symbol('invalid policy result');
10
11
 
@@ -21,7 +22,8 @@ const isValidValue = (value) => {
21
22
  if (isReference(value)) {
22
23
  return Object.keys(value).length === 1 && Object.hasOwn(value, 'ref') && isValidRef(value.ref);
23
24
  }
24
- return value !== undefined && typeof value !== 'function' && typeof value !== 'symbol';
25
+ // NaN equals nothing, so a literal NaN is rejected, as in literal `in` lists.
26
+ return value !== undefined && typeof value !== 'function' && typeof value !== 'symbol' && !Number.isNaN(value);
25
27
  };
26
28
 
27
29
  const isDenseArray = value => Array.isArray(value)
@@ -105,38 +107,93 @@ const resolveValue = (value, context) => {
105
107
  return isReference(value) ? resolveRef(value.ref, context) : value;
106
108
  };
107
109
 
108
- // Registered ObjectIds compare by hexadecimal value; every other value compares strictly.
110
+ /**
111
+ * The time value of a genuine Date from any realm (NaN when invalid), or undefined for other values.
112
+ * The tag skips other objects cheaply; getTime then rejects objects that only spoof the tag.
113
+ */
114
+ const dateTime = (value) => {
115
+ if (value === null || typeof value !== 'object') return undefined;
116
+ try {
117
+ return Object.prototype.toString.call(value) === '[object Date]' ? Date.prototype.getTime.call(value) : undefined;
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ };
122
+
123
+ // Registered ObjectIds compare by hexadecimal value; other values except Dates compare strictly.
109
124
  const comparable = value => normalizeObjectId(value) ?? value;
110
125
 
111
- // Strict equality never matches values of different types, such as a string and a number or a
112
- // primitive and a list. Such a comparison is invalid, so `not` cannot turn it into a grant.
113
- // null stays comparable with anything, as an explicit check for a missing value.
114
- const isMismatch = (left, right) => left !== null && right !== null && typeof left !== typeof right;
126
+ /**
127
+ * Classify the left value of a comparison once: a promise, null, NaN, a Date by its time, or another
128
+ * value in its comparable form. A membership list then repeats only the checks on its items.
129
+ */
130
+ const classifyOperand = (value) => {
131
+ if (isThenable(value)) return { kind: 'promise', value: undefined };
132
+ if (value === null) return { kind: 'null', value };
133
+ if (Number.isNaN(value)) return { kind: 'nan', value };
134
+ const time = dateTime(value);
135
+ if (time !== undefined) return { kind: 'date', value: time };
136
+ return { kind: 'value', value: comparable(value) };
137
+ };
138
+
139
+ /**
140
+ * Compare a classified value with a resolved value: true, false, or invalidResult when they cannot
141
+ * be compared reliably. A promise is not its eventual value, so it is never compared; a rejected
142
+ * native one is handled, so it cannot crash the process. null stays comparable with anything else,
143
+ * as an explicit check for a missing value. NaN equals nothing, and Dates compare by time, only with
144
+ * valid Dates. Otherwise strict equality never matches values of different types, such as a string
145
+ * and a number. Such comparisons are invalid, so `not` cannot turn them into a grant.
146
+ */
147
+ const compareOperand = (left, right) => {
148
+ if (isThenable(right)) {
149
+ discardThenable(right);
150
+ return invalidResult;
151
+ }
152
+ if (left.kind === 'promise') return invalidResult;
153
+ if (left.kind === 'null' || right === null) return left.kind === 'null' && right === null;
154
+ if (left.kind === 'nan' || Number.isNaN(right)) return invalidResult;
155
+ const rightTime = dateTime(right);
156
+ if (left.kind === 'date' || rightTime !== undefined) {
157
+ if (left.kind !== 'date' || rightTime === undefined || Number.isNaN(left.value) || Number.isNaN(rightTime)) {
158
+ return invalidResult;
159
+ }
160
+ return left.value === rightTime;
161
+ }
162
+ const other = comparable(right);
163
+ return typeof left.value !== typeof other ? invalidResult : left.value === other;
164
+ };
115
165
 
116
166
  const evaluateMembership = (target, list) => {
117
167
  // A list or plain object is not a member value.
118
168
  if (!Array.isArray(list) || isPlainValue(target)) return invalidResult;
169
+ const operand = classifyOperand(target);
119
170
  // Never call the list's own includes: Mongoose arrays cast and compare loosely.
120
171
  let mismatched = false;
121
172
  for (let index = 0; index < list.length; index++) {
122
- const item = comparable(list[index]);
123
- if (item === target) return true;
124
- if (isMismatch(target, item)) mismatched = true;
173
+ const result = compareOperand(operand, list[index]);
174
+ if (result === true) return true;
175
+ if (result === invalidResult) mismatched = true;
125
176
  }
126
- // Without a match, an item of another type means the list could not be checked reliably.
177
+ // Without a match, an item that could not be compared means the list was not checked reliably.
127
178
  return mismatched ? invalidResult : false;
128
179
  };
129
180
 
130
181
  const evaluateComparison = (operator, operands, context) => {
131
182
  const left = resolveValue(operands[0], context);
132
183
  const right = resolveValue(operands[1], context);
133
- if (left === undefined || right === undefined || typeof left === 'function' || typeof right === 'function') {
184
+ // A promise is not its eventual value, so it cannot be compared, even with an empty list. A rejected
185
+ // native promise a ref yields, such as a lazy relation that failed to load, is handled so it cannot
186
+ // crash the process.
187
+ const leftThenable = isThenable(left);
188
+ const rightThenable = isThenable(right);
189
+ if (leftThenable) discardThenable(left);
190
+ if (rightThenable) discardThenable(right);
191
+ if (left === undefined || right === undefined || typeof left === 'function' || typeof right === 'function'
192
+ || leftThenable || rightThenable) {
134
193
  return invalidResult;
135
194
  }
136
- const target = comparable(left);
137
- if (operator === 'in') return evaluateMembership(target, right);
138
- const other = comparable(right);
139
- return isMismatch(target, other) ? invalidResult : target === other;
195
+ if (operator === 'in') return evaluateMembership(left, right);
196
+ return compareOperand(classifyOperand(left), right);
140
197
  };
141
198
 
142
199
  const evaluateAllOf = (expressions, context) => {
@@ -209,6 +266,9 @@ const compileOperand = (operator, value, index) => {
209
266
  if (isReference(value)) return new CompiledOperand(true, value.ref);
210
267
  // A literal membership list is copied, so only its validated items are compared.
211
268
  if (operator === 'in' && index === 1) return new CompiledOperand(false, Object.freeze(copyArray(value)));
269
+ // A literal Date compares by time, so a private copy keeps setTime on the configured one out.
270
+ const time = dateTime(value);
271
+ if (time !== undefined) return new CompiledOperand(false, new Date(time));
212
272
  // Other literals keep their identity, which equality compares.
213
273
  return new CompiledOperand(false, value);
214
274
  };
@@ -219,7 +279,8 @@ const snapshotOperands = (operator, operand) => Object.freeze(
219
279
 
220
280
  /**
221
281
  * Copy the validated structure, so evaluation needs no revalidation and later changes to the
222
- * configured object cannot make it malformed. Other literal operands keep their identity.
282
+ * configured object cannot make it malformed. Date literals are copied; other literal operands keep
283
+ * their identity.
223
284
  */
224
285
  const snapshotExpression = (expression) => {
225
286
  if (typeof expression === 'boolean') return expression;
package/src/auth/index.js CHANGED
@@ -31,11 +31,23 @@
31
31
  * const yoga = createYoga({ schema, plugins: [authPlugin] });
32
32
  */
33
33
 
34
- import { GraphQLError, GraphQLObjectType, defaultFieldResolver, __Field } from 'graphql';
34
+ import {
35
+ GraphQLError,
36
+ GraphQLEnumType,
37
+ GraphQLInputObjectType,
38
+ GraphQLInterfaceType,
39
+ GraphQLObjectType,
40
+ GraphQLScalarType,
41
+ GraphQLUnionType,
42
+ defaultFieldResolver,
43
+ __Field,
44
+ } from 'graphql';
35
45
  import SimfinityError from '../errors/simfinity.error.js';
36
46
  import { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
37
47
  import { isPolicyExpression, createRuleFromExpression, evaluateExpression } from './expressions.js';
38
48
  import { collectQueryPaths, walkQueryPath } from '../query-plan.js';
49
+ import { relationFieldOwners } from '../relation-owners.js';
50
+ import { isThenable } from './thenable.js';
39
51
  import {
40
52
  resolvePath,
41
53
  requireAuth,
@@ -145,8 +157,20 @@ const isExemptMetadataType = (permissions, type) => isSimfinityMetadataType(type
145
157
  // these schemas; in a schema no instance processed, every wrapper keeps enforcing its rules.
146
158
  const authorizedSchemas = new WeakSet();
147
159
 
148
- const isThenable = value => value !== null && (typeof value === 'object' || typeof value === 'function')
149
- && typeof value.then === 'function';
160
+ const namedTypeClasses = [
161
+ GraphQLObjectType, GraphQLInterfaceType, GraphQLUnionType,
162
+ GraphQLScalarType, GraphQLEnumType, GraphQLInputObjectType,
163
+ ];
164
+
165
+ /**
166
+ * A type created by another copy of graphql (a second installed version, or its ESM and CommonJS
167
+ * builds loaded together) fails `instanceof`, so it cannot be classified and its fields could not be
168
+ * protected. graphql's own isObjectType only reports such copies outside production.
169
+ */
170
+ const assertSameGraphQLModule = (typeName, type) => {
171
+ if (namedTypeClasses.some(NamedType => type instanceof NamedType)) return;
172
+ throw new TypeError(`Cannot authorize type ${typeName}: it was created by a different copy of the graphql module than the auth plugin uses. Install a single graphql version and load it through one entry point.`);
173
+ };
150
174
 
151
175
  const validateConfiguration = (permissions, defaultPolicy) => {
152
176
  if (defaultPolicy !== 'ALLOW' && defaultPolicy !== 'DENY') {
@@ -348,6 +372,9 @@ export const createFieldMiddleware = (permissions, options = {}) => createAuthMi
348
372
  * and rebuilds the schema), this plugin mutates resolvers directly on the existing schema,
349
373
  * avoiding schema reconstruction and the duplicate-type errors it can cause.
350
374
  *
375
+ * `onSchemaChange` throws `TypeError` before wrapping any field when a schema type was created by
376
+ * another copy of the graphql module, instead of leaving that type's fields unprotected.
377
+ *
351
378
  * @param {PermissionSchema} permissions - The permission schema object
352
379
  * @param {AuthMiddlewareOptions} [options={}] - Plugin options
353
380
  * @returns {Object} An Envelop plugin with an `onSchemaChange` hook
@@ -441,7 +468,7 @@ export const createAuthPlugin = (permissions, options = {}) => {
441
468
  return denied();
442
469
  };
443
470
 
444
- return (parent, args, ctx, info) => {
471
+ const resolver = (parent, args, ctx, info) => {
445
472
  // In a schema that only other auth plugin instances processed, their rules apply, not these.
446
473
  if (info?.schema && authorizedSchemas.has(info.schema) && !processedSchemas.has(info.schema)) {
447
474
  return originalResolve(parent, args, ctx, info);
@@ -454,6 +481,11 @@ export const createAuthPlugin = (permissions, options = {}) => {
454
481
  }
455
482
  return runRules(0, parent, args, ctx, info);
456
483
  };
484
+ // The wrapper still reads through the runtime that generated a relation resolver, so a field
485
+ // copied with it stays bound to that runtime.
486
+ const owner = relationFieldOwners.get(originalResolve);
487
+ if (owner !== undefined) relationFieldOwners.set(resolver, owner);
488
+ return resolver;
457
489
  };
458
490
 
459
491
  const wrapSchemaResolvers = (schema) => {
@@ -461,11 +493,15 @@ export const createAuthPlugin = (permissions, options = {}) => {
461
493
  validateConfiguration(permissions, defaultPolicy);
462
494
  warnAboutQueryRoot(schema);
463
495
 
464
- const typeMap = schema.getTypeMap();
465
-
466
- for (const [typeName, type] of Object.entries(typeMap)) {
467
- if (!(type instanceof GraphQLObjectType) || typeName.startsWith('__') || isExemptMetadataType(permissions, type)) continue;
496
+ // Check every type before wrapping any field, so an unsupported schema is left unchanged.
497
+ const objectTypes = [];
498
+ for (const [typeName, type] of Object.entries(schema.getTypeMap())) {
499
+ if (typeName.startsWith('__')) continue;
500
+ assertSameGraphQLModule(typeName, type);
501
+ if (type instanceof GraphQLObjectType && !isExemptMetadataType(permissions, type)) objectTypes.push([typeName, type]);
502
+ }
468
503
 
504
+ for (const [typeName, type] of objectTypes) {
469
505
  const fields = type.getFields();
470
506
 
471
507
  for (const [fieldName, field] of Object.entries(fields)) {
package/src/auth/rules.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { UnauthenticatedError, ForbiddenError } from './errors.js';
2
2
  import { normalizeObjectId } from './object-id.js';
3
+ import { isThenable, discardThenable } from './thenable.js';
3
4
 
4
5
  /**
5
6
  * Resolves a value from an object using a dotted path string or function.
7
+ * A function's result is returned as is, including a promise; the built-in rules deny such results.
6
8
  * @param {Object} obj - The object to resolve from
7
9
  * @param {string|Function} pathOrFn - Dotted path (e.g., 'user.profile.id') or function to extract value
8
10
  * @returns {*} The resolved value or undefined if not found
@@ -44,6 +46,30 @@ const ruleFromCheck = (check) => {
44
46
 
45
47
  const unauthenticated = () => new UnauthenticatedError('You must be logged in to access this resource');
46
48
 
49
+ // Built-in checks are synchronous, so they cannot wait for a path that yields a promise or another
50
+ // thenable. Such a value is never truthy evidence of a user or claim: it denies with this error.
51
+ const pendingValue = () => new TypeError(
52
+ 'Authorization paths must return values synchronously; resolve asynchronous data in the GraphQL context or use an async rule',
53
+ );
54
+ const pending = Symbol('pending authorization value');
55
+
56
+ const isAsyncFunction = value => ['[object AsyncFunction]', '[object AsyncGeneratorFunction]']
57
+ .includes(Object.prototype.toString.call(value));
58
+
59
+ const validatePath = (name, pathOrFn) => {
60
+ if (isAsyncFunction(pathOrFn)) {
61
+ throw new TypeError(`${name} must be a dotted path or a synchronous extractor function`);
62
+ }
63
+ };
64
+
65
+ /** Resolve a path for a built-in check; a thenable result is discarded and reported as pending. */
66
+ const readPath = (obj, pathOrFn) => {
67
+ const value = resolvePath(obj, pathOrFn);
68
+ if (!isThenable(value)) return value;
69
+ discardThenable(value);
70
+ return pending;
71
+ };
72
+
47
73
  /**
48
74
  * Rule that requires the user to be authenticated
49
75
  * Checks for user existence at the specified path in context
@@ -55,9 +81,12 @@ const unauthenticated = () => new UnauthenticatedError('You must be logged in to
55
81
  * requireAuth('session.currentUser') // checks ctx.session.currentUser
56
82
  */
57
83
  export const requireAuth = (userPath = 'user') => {
84
+ validatePath('userPath', userPath);
58
85
  return ruleFromCheck((_parent, _args, ctx) => {
59
- if (!ctx || !resolvePath(ctx, userPath)) return unauthenticated;
60
- return true;
86
+ if (!ctx) return unauthenticated;
87
+ const user = readPath(ctx, userPath);
88
+ if (user === pending) return pendingValue;
89
+ return user ? true : unauthenticated;
61
90
  });
62
91
  };
63
92
 
@@ -80,14 +109,18 @@ export const requireRole = (role, options = {}) => {
80
109
  throw new TypeError('Required roles must be a nonempty string or array of nonempty strings');
81
110
  }
82
111
  const { userPath = 'user', rolePath = 'role' } = options;
112
+ validatePath('userPath', userPath);
113
+ validatePath('rolePath', rolePath);
83
114
  const forbidden = () => new ForbiddenError(`Requires role: ${roles.join(' or ')}`);
84
115
 
85
116
  return ruleFromCheck((_parent, _args, ctx) => {
86
117
  if (!ctx) return unauthenticated;
87
- const user = resolvePath(ctx, userPath);
118
+ const user = readPath(ctx, userPath);
119
+ if (user === pending) return pendingValue;
88
120
  if (!user) return unauthenticated;
89
121
 
90
- const userRole = resolvePath(user, rolePath);
122
+ const userRole = readPath(user, rolePath);
123
+ if (userRole === pending) return pendingValue;
91
124
  return roles.includes(userRole) ? true : forbidden;
92
125
  });
93
126
  };
@@ -130,13 +163,18 @@ export const requirePermission = (permission, options = {}) => {
130
163
  throw new TypeError('Required permissions must be a nonempty string or array of nonempty strings');
131
164
  }
132
165
  const { userPath = 'user', permissionsPath = 'permissions' } = options;
166
+ validatePath('userPath', userPath);
167
+ validatePath('permissionsPath', permissionsPath);
133
168
 
134
169
  return ruleFromCheck((_parent, _args, ctx) => {
135
170
  if (!ctx) return unauthenticated;
136
- const user = resolvePath(ctx, userPath);
171
+ const user = readPath(ctx, userPath);
172
+ if (user === pending) return pendingValue;
137
173
  if (!user) return unauthenticated;
138
174
 
139
- const held = findHeldPermissions(resolvePath(user, permissionsPath), requiredPermissions);
175
+ const claims = readPath(user, permissionsPath);
176
+ if (claims === pending) return pendingValue;
177
+ const held = findHeldPermissions(claims, requiredPermissions);
140
178
  if (held === null) {
141
179
  return () => new ForbiddenError('User permissions must be an array of nonempty strings');
142
180
  }
@@ -244,17 +282,23 @@ const normalizeOwnerId = (value) => {
244
282
  */
245
283
  export const isOwner = (ownerField = 'userId', userIdField = 'id', options = {}) => {
246
284
  const { userPath = 'user' } = options;
285
+ validatePath('ownerField', ownerField);
286
+ validatePath('userIdField', userIdField);
287
+ validatePath('userPath', userPath);
247
288
 
248
289
  return ruleFromCheck((parent, _args, ctx) => {
249
290
  if (!ctx) return unauthenticated;
250
- const user = resolvePath(ctx, userPath);
291
+ const user = readPath(ctx, userPath);
292
+ if (user === pending) return pendingValue;
251
293
  if (!user) return unauthenticated;
252
294
 
253
295
  // Get ownerId from parent (using path or function)
254
- const ownerId = normalizeOwnerId(resolvePath(parent, ownerField));
255
-
296
+ const owner = readPath(parent, ownerField);
256
297
  // Get userId from user object (using path or function)
257
- const userId = normalizeOwnerId(resolvePath(user, userIdField));
298
+ const id = readPath(user, userIdField);
299
+ if (owner === pending || id === pending) return pendingValue;
300
+ const ownerId = normalizeOwnerId(owner);
301
+ const userId = normalizeOwnerId(id);
258
302
 
259
303
  if (ownerId === undefined || userId === undefined) {
260
304
  return false;
@@ -0,0 +1,17 @@
1
+ /** Internal: true for promises and other values that `await` would wait for. */
2
+ export const isThenable = value => value !== null && (typeof value === 'object' || typeof value === 'function')
3
+ && typeof value.then === 'function';
4
+
5
+ /**
6
+ * Internal: handle a native promise from any realm whose result is never used, so its rejection
7
+ * cannot crash the process. The built-in then() is called directly: it throws for anything that is
8
+ * not a native promise without running its code, and skips an overriding then(). Other thenables are
9
+ * left untouched, because calling their then() can start work, such as a lazy query builder.
10
+ */
11
+ export const discardThenable = (value) => {
12
+ try {
13
+ Promise.prototype.then.call(value, undefined, () => {});
14
+ } catch {
15
+ // Not a native promise: there is no rejection to handle without running its own then().
16
+ }
17
+ };
package/src/index.js CHANGED
@@ -11,6 +11,7 @@ export { default as QLOperator } from './const/QLOperator.js';
11
11
  export { default as QLSort } from './const/QLSort.js';
12
12
  export { default as QLValue } from './const/QLValue.js';
13
13
  export { configureQueryLimits, paginationStages } from './query-limits.js';
14
+ export { configureMutationLimits } from './mutation-limits.js';
14
15
  export { default as auth } from './auth/index.js';
15
16
  export { default as plugins } from './plugins.js';
16
17
  export { default as scalars } from './scalars.js';
@@ -0,0 +1,24 @@
1
+ import SimfinityError from './errors/simfinity.error.js';
2
+
3
+ // Process-wide, like the query limits. null leaves nested collection operations unlimited.
4
+ let maxNestedOperations = null;
5
+
6
+ export const configureMutationLimits = (options = {}) => {
7
+ if (!options || typeof options !== 'object' || Array.isArray(options)
8
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(options))) {
9
+ throw new SimfinityError('Mutation limit options must be a plain object', 'INVALID_MUTATION_LIMITS', 400);
10
+ }
11
+ // A misspelled option must not silently reset the limit to unlimited.
12
+ const unknown = Reflect.ownKeys(options).find((key) => key !== 'maxNestedOperations');
13
+ if (unknown !== undefined) {
14
+ throw new SimfinityError(`Unknown mutation limit option ${String(unknown)}; use maxNestedOperations`,
15
+ 'INVALID_MUTATION_LIMITS', 400);
16
+ }
17
+ const { maxNestedOperations: value = null } = options;
18
+ if (value !== null && (!Number.isSafeInteger(value) || value < 0)) {
19
+ throw new SimfinityError('maxNestedOperations must be null or a non-negative safe integer', 'INVALID_MUTATION_LIMITS', 400);
20
+ }
21
+ maxNestedOperations = value;
22
+ };
23
+
24
+ export const getMaxNestedOperations = () => maxNestedOperations;
package/src/query-plan.js CHANGED
@@ -4,12 +4,17 @@ 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
+ // `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
8
+ // that name is filtered like any other.
9
+ const listReserved = new Set(['AND', 'OR', 'sort', 'pagination']);
10
+ const controlArgs = (operation) => (operation === 'aggregate' ? reserved : listReserved);
7
11
  const listOf = (value) => (Array.isArray(value) ? value : []);
8
12
 
9
13
  /**
10
14
  * Internal: the model paths named by generated query arguments, as `{ segments, group }` entries.
11
15
  * 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
16
+ * groupId and fact paths; aggregate sort terms name result keys. Outside aggregate mode a key
17
+ * named `aggregation` is a field filter like any other. `group` is the AND/OR group
13
18
  * object whose `conditions` hold the path, or null for the other forms. Malformed entries are
14
19
  * left to the backends.
15
20
  */
@@ -27,8 +32,9 @@ export const collectQueryPathEntries = (args, operation = 'find') => {
27
32
  }
28
33
  for (const item of [...listOf(group.AND), ...listOf(group.OR)]) visit(item);
29
34
  };
35
+ const controls = controlArgs(operation);
30
36
  for (const [name, value] of Object.entries(args)) {
31
- if (reserved.has(name) || value == null) continue;
37
+ if (controls.has(name) || value == null) continue;
32
38
  const terms = listOf(value.terms).filter((term) => typeof term?.path === 'string' && term.path);
33
39
  if (terms.length) for (const term of terms) add(`${name}.${term.path}`);
34
40
  else add(name);
@@ -100,9 +106,9 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
100
106
  if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
101
107
  return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
102
108
  };
103
- const validateGroup = (value) => {
109
+ const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
104
110
  if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
105
- for (const key of ['conditions', 'AND', 'OR']) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
111
+ for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
106
112
  };
107
113
  const group = (value, depth = 0) => {
108
114
  validateGroup(value);
@@ -117,10 +123,12 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
117
123
  terms.push(combine('or', (value.OR || []).map((item) => group(item, depth + 1))));
118
124
  return combine('and', terms);
119
125
  };
120
- validateGroup(input);
126
+ // Top-level arguments are field filters plus AND/OR; `conditions` only lists conditions inside groups.
127
+ validateGroup(input, ['AND', 'OR']);
128
+ const controls = controlArgs(mode);
121
129
  const terms = [];
122
130
  for (const [name, value] of Object.entries(input)) {
123
- if (reserved.has(name) || value == null) continue;
131
+ if (controls.has(name) || value == null) continue;
124
132
  const field = resolveModelPath(models, entityName, name).at(-1);
125
133
  if (typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter object');
126
134
  if (field.kind !== 'scalar' && !field.inferred && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
@@ -0,0 +1,5 @@
1
+ // Internal: generated relation resolvers, the extensions object given to each field that holds one,
2
+ // and Simfinity's own wrappers of those resolvers, mapped to the runtime that generated them.
3
+ // toConfig() copies and field spreads keep the resolver or the extensions object, so a field copied
4
+ // from a bound type still shows whose adapter, registrations and middleware it reads through.
5
+ export const relationFieldOwners = new WeakMap();
@@ -0,0 +1,58 @@
1
+ import { GraphQLList, GraphQLNonNull } from 'graphql';
2
+
3
+ // Internal helpers that decide where a relation stores its link and which types keep a stored
4
+ // identity. They are shared by the core runtime and the MongoDB adapter through the
5
+ // `./internal/relation-storage` subpath, which is not public API.
6
+
7
+ const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
8
+
9
+ export const getListShape = (type) => {
10
+ const outerNonNull = type instanceof GraphQLNonNull;
11
+ const listType = unwrapNonNull(type);
12
+ if (!(listType instanceof GraphQLList)) return null;
13
+ const itemNonNull = listType.ofType instanceof GraphQLNonNull;
14
+ return {
15
+ outerNonNull,
16
+ itemNonNull,
17
+ itemType: unwrapNonNull(listType.ofType),
18
+ };
19
+ };
20
+
21
+ export const getFieldStorageName = (fieldName, field) => {
22
+ const relation = field.extensions?.relation;
23
+ return relation && !relation.embedded && !getListShape(field.type)
24
+ ? relation.connectionField || fieldName
25
+ : fieldName;
26
+ };
27
+
28
+ // Entity types, which keep their own stored identity. The generated id resolver lives on the type
29
+ // object, which runtimes may share, so the identity rule is per type object, not per runtime
30
+ // registration: a store path for `id` follows the resolver any runtime installed on the type.
31
+ const storedIdentityTypes = new WeakSet();
32
+
33
+ export const markStoredIdentity = (gqltype) => {
34
+ storedIdentityTypes.add(gqltype);
35
+ };
36
+
37
+ export const hasStoredIdentity = (gqltype) => storedIdentityTypes.has(gqltype);
38
+
39
+ export const normalizeConnectionField = (childType, declaredFieldName) => {
40
+ if (!declaredFieldName) {
41
+ return { declaredFieldName, graphqlFieldName: null, storageFieldName: null };
42
+ }
43
+ const childFields = childType.getFields();
44
+ let graphqlFieldName = childFields[declaredFieldName] ? declaredFieldName : null;
45
+ if (!graphqlFieldName) {
46
+ const aliasedField = Object.entries(childFields).find(([fieldName, field]) => (
47
+ getFieldStorageName(fieldName, field) === declaredFieldName
48
+ ));
49
+ graphqlFieldName = aliasedField?.[0] || null;
50
+ }
51
+ return {
52
+ declaredFieldName,
53
+ graphqlFieldName,
54
+ storageFieldName: graphqlFieldName
55
+ ? getFieldStorageName(graphqlFieldName, childFields[graphqlFieldName])
56
+ : declaredFieldName,
57
+ };
58
+ };