@simtlix/simfinity-core 3.5.2 → 3.5.3

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,15 +8,20 @@ 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
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 it.
19
20
 
21
+ 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.
22
+
23
+ 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`).
24
+
20
25
  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
26
 
22
27
  Importing the runtime installs Simfinity's `__Field.extensions` introspection field once per GraphQL module instance. Multiple runtimes and driver packages can be imported together without cloning schemas or duplicating introspection types.
@@ -27,4 +32,4 @@ Importing the runtime installs Simfinity's `__Field.extensions` introspection fi
27
32
 
28
33
  `createQueryPlan` and `resolveModelPath` turn Simfinity filter, sort, pagination, and aggregation inputs into a driver-neutral plan resolved against that metadata.
29
34
 
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.
35
+ 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.3",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.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;
@@ -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();