@simtlix/simfinity-core 3.15.0 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,17 +12,17 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
12
12
 
13
13
  - `connect`, `addNoEndpointType`, and `createSchema` for registration and schema creation;
14
14
  - `getModel`, `getType`, `getInputType`, and `getRegistrations` for runtime inspection;
15
- - `use`, `registerMutation`, and `saveObject` for middleware and write orchestration;
15
+ - `use`, `registerMutation`, and `saveObject` for middleware and write orchestration, and `updateObject`, `deleteObject` and `transitionObject`, which run the lifecycle of the generated update, delete and state action mutations from code;
16
16
  - `registerCustomQuery` for reads whose results are not stored records, such as previews, other sources or permission evaluations;
17
17
  - `registerView` for a named list read of one registered type, with params and a fixed predicate, run through the type's scope;
18
18
  - `preventCreatingCollection` to pass validation-only storage setup to an adapter;
19
- - `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size, compiled-frame, frame-nesting and nested-operation limits.
19
+ - `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size, compiled-frame, frame-nesting, nested-operation and transaction-operation limits (`maxTransactionOperations`).
20
20
 
21
21
  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. The GraphQL types that `prepare()` and `createModel()` receive still contain virtual fields, which must never be stored: an adapter that builds storage from a type's fields skips each field for which `isVirtualField(field)`, exported by this package with `virtualFieldOptions(field)`, returns true, on entities and embedded types alike, or builds it from `describeModels()`, which leaves them out. Generated writes, filters, sorts and aggregations never name a virtual field, but a controller hook can still assign one. 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. An adapter can define `readByIdIsInert(model)`, returning `true` when a `getById` of that model has no side effects and `update()` and `delete()` reject the same malformed IDs with `NOT_VALID_ID` (400) and return null for a missing record; the runtime then skips the reads before deletes without an `onDelete` hook and before updates that merge no embedded value and have neither `onUpdating` nor nested collection items. The SQL adapter always reports it, and the MongoDB adapter does for models without `find`, `findOne`, `findOneAndUpdate` or `findOneAndDelete` middleware and without application `_id` setters or overrides of the statics those reads and writes call. When `getById` rejects a stored reference value that way, including one that `castId` rejected first, the generated reference field fails with an `InternalServerError` (500), since the client did not send the value. `update()` resolves to the updated record as stored, or null; nested collection children of that record are linked to and checked against its `_id`, else `id`, else the requested ID. 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. Simfinity then reads as `null` an object that holds no data when a required member would read as `null`, not counting `id`, `readOnly` and virtual members, interface and union members, members with an application resolver and referenced collections; runtimes that share the type read the same. It reads the stored members, never calling a getter, and counts a stored `_id` as `id`; it reads nothing from objects whose type has no member that counts, and an error while reading returns the object unchanged. An adapter whose hydrated records render members through getters can also define `rawEmbeddedValue(value)`, which returns a record's stored data as a plain object, or the record unchanged when that data does not show what it renders; a value that is not a plain object counts as data. The MongoDB adapter defines both.
22
22
 
23
23
  An adapter can also define `findChildrenBatch(model, type, connectionField, parentIds, args, { context })`, the batch form of `findChildren` for generated collection fields, and a synchronous `batchesChildren(model, type, connectionField)` predicate. The runtime calls both as adapter methods and checks them on every read. When the method is missing or the predicate returns `false`, each field calls `findChildren` at once with the arguments object it built, as before. Otherwise, within one request (a context object), the runtime groups the collection reads of one related type and connection storage field whose effective arguments (after middleware, the scope and joined scopes) hold the same plain data and are requested in the same tick, that is before the promise jobs queued so far have run. It calls the method with 2 to 100 distinct parent IDs, each a raw ID that `castId` accepts without changing its string form, and with a copy of the arguments that the method may change; a group with one distinct parent calls `findChildren`. Return one array per parent ID, in order, each holding exactly the records, order and page that `findChildren` returns for that parent; records should show their parent under the connection field, or not show one. Fields of one parent share its records, each with its own array. After a rejection, or a result of another shape or with records of another parent, each field reads alone with `findChildren`; failures other than a `SimfinityError`, and results of another shape, are logged once per type. Pairing rule: the batch must reproduce `this.findChildren`, so `batchesChildren` must return `false` when `this.findChildren` is not the adapter's own function, as with a spread copy or an `Object.assign` override that changes how children are read. The bundled adapters follow this rule: both define the two members only with `batchedCollections: true`: the SQL adapter when the schema is prepared and its plugin's `compileChildrenQuery` is paired with its `compileQuery`, and the MongoDB adapter at creation. To opt out on one adapter instance, set `adapter.findChildrenBatch = undefined` (deleting it does nothing for a class whose method is on the prototype), or make `batchesChildren` return `false`. A copy that overrides `getById` still inherits the base `getByIds`, as before.
24
24
 
25
- An adapter declares the filter operators it compiles as `queryFeatures: { operators }`, a list of `QLOperator` values that includes `EQ`, which a condition without an operator means; names this version does not know are ignored. `createRuntime()` reads it once and throws `INVALID_QUERY_FEATURES` (500) unless it is `undefined`, `null` or an object whose `operators` is an array of strings and whose optional `referenceEmptiness` is a boolean, `undefined` or `null`. Without a declaration the adapter supports the operators of 3.7.0, `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN` and `LIKE`, so it never receives a text operator such as `ILIKE`, or an emptiness operator, `IS_EMPTY` or `IS_NOT_EMPTY`, that it would drop or misread. A list, count, aggregate or collection read, or a by-ID read through a `get_by_id` scope, that uses another `QLOperator` value fails with `UNSUPPORTED_QUERY` (400): the runtime checks the client's arguments before middleware runs and the final arguments, with what middleware, scopes and joined scopes added, before it calls the adapter. Unknown operators on actual filter predicates fail with `INVALID_FILTER_OPERATOR` (400) before the adapter is called. An adapter that receives the text operators must apply their rules: literal values, a `String` field or list, and `INVALID_FILTER_VALUE` (400) for a value that is not a string or contains U+0000. `createQueryPlan(models, entity, args, { operators })` validates them and rejects operators outside `operators` with `UNSUPPORTED_QUERY`. An adapter that declares `IS_EMPTY` and `IS_NOT_EMPTY` must compile their semantics on the queried record's own fields, where `IS_NOT_EMPTY` is the exact complement of `IS_EMPTY`: a `String` is empty when missing, `null` or `""`, any other scalar or enum when missing or `null`, a list when missing, `null` or `[]`, and an embedded object when missing, `null` or every stored member is empty. The runtime rejects a value other than `null` and a path inside a list before it calls the adapter, and also emptiness through references and collections unless the adapter declares `referenceEmptiness: true`, so the adapter receives conditions whose `value` may be absent and whose term or group `path` may be omitted, `null` or `""` to name the field itself; `createQueryPlan` plans them as `{ kind: 'predicate', path, operator, value: null }`, whose `path` may end at a scalar list, an embedded list or an embedded object. See [empty values](https://simtlix.github.io/simfinity.js/guide/queries.html#empty-values). An adapter that declares `referenceEmptiness: true` also receives emptiness conditions whose path crosses single references and ends at a reference or a collection, and embedded objects with reference and collection members. A client's such term or group condition carries the key `__scope: { id, scopes }`, where `scopes` holds, for each reference it crosses and each reference, collection or member it ends at or reads, keyed by the dotted path relative to the containing frame, the arguments that the target type's `find` scope produced, or `null` when it added nothing. The adapter must join a crossed reference only where that scope admits the target, and count at an end or member only the records that the scope admits, so that a record the caller may not see reads as a missing one, also on a later hop with a `null` scope; a condition without `__scope` reads stored data, as server conditions and unmarked clients on unscoped chains do. `createQueryPlan(models, entity, args, { operators, referenceEmptiness: true })` lifts each filled `__scope` into the predicate's `scopes`, planning each scope on its target type, and throws `DETACHED_CLIENT_FILTER` (500) for a `__scope` that lacks a path; its `referenceEmptiness` option defaults to `true` when `operators` is omitted and to `false` otherwise. See [references and collections](https://simtlix.github.io/simfinity.js/guide/queries.html#references-and-collections).
25
+ An adapter declares the filter operators it compiles as `queryFeatures: { operators }`, a list of `QLOperator` values that includes `EQ`, which a condition without an operator means; names this version does not know are ignored, until a version that knows them, so a declaration that listed `CONTAINS_ALL` or `IIN` before they existed starts receiving them on upgrade: list only the operators the adapter compiles. `createRuntime()` reads it once and throws `INVALID_QUERY_FEATURES` (500) unless it is `undefined`, `null` or an object whose `operators` is an array of strings and whose optional `referenceEmptiness` is a boolean, `undefined` or `null`. Without a declaration the adapter supports the operators of 3.7.0, `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN` and `LIKE`, so it never receives a text operator such as `ILIKE`, an emptiness operator, `IS_EMPTY` or `IS_NOT_EMPTY`, or a list operator, `CONTAINS_ALL` or `IIN`, that it would drop or misread. A list, count, aggregate or collection read, or a by-ID read through a `get_by_id` scope, that uses another `QLOperator` value fails with `UNSUPPORTED_QUERY` (400): the runtime checks the client's arguments before middleware runs and the final arguments, with what middleware, scopes and joined scopes added, before it calls the adapter. Unknown operators on actual filter predicates fail with `INVALID_FILTER_OPERATOR` (400) before the adapter is called. An adapter that receives the text operators must apply their rules: literal values, a `String` field or list, and `INVALID_FILTER_VALUE` (400) for a value that is not a string or contains U+0000. `createQueryPlan(models, entity, args, { operators })` validates them and rejects operators outside `operators` with `UNSUPPORTED_QUERY`. An adapter that declares `IIN` receives an array of strings without U+0000, possibly empty, on a `String` field or list, and must match where `IEQ` would match one of them, under the same case-folding rules, and nothing for `[]`. An adapter that declares `CONTAINS_ALL` receives a non-empty array of non-null scalars, validated but not cast, on a path that ends at a scalar list or at a scalar read through embedded lists and crosses no collection and no reference after an embedded list; it must cast each element as for `IN` and match the records whose values at the path, those of every item of embedded lists included, hold every element, and stay two-valued under negation, where a missing or `null` list holds none. See [filters on lists](https://simtlix.github.io/simfinity.js/guide/queries.html#filters-on-lists). An adapter that declares `IS_EMPTY` and `IS_NOT_EMPTY` must compile their semantics on the queried record's own fields, where `IS_NOT_EMPTY` is the exact complement of `IS_EMPTY`: a `String` is empty when missing, `null` or `""`, any other scalar or enum when missing or `null`, a list when missing, `null` or `[]`, and an embedded object when missing, `null` or every stored member is empty. The runtime rejects a value other than `null` and a path inside a list before it calls the adapter, and also emptiness through references and collections unless the adapter declares `referenceEmptiness: true`, so the adapter receives conditions whose `value` may be absent and whose term or group `path` may be omitted, `null` or `""` to name the field itself; `createQueryPlan` plans them as `{ kind: 'predicate', path, operator, value: null }`, whose `path` may end at a scalar list, an embedded list or an embedded object. See [empty values](https://simtlix.github.io/simfinity.js/guide/queries.html#empty-values). An adapter that declares `referenceEmptiness: true` also receives emptiness conditions whose path crosses single references and ends at a reference or a collection, and embedded objects with reference and collection members. A client's such term or group condition carries the key `__scope: { id, scopes }`, where `scopes` holds, for each reference it crosses and each reference, collection or member it ends at or reads, keyed by the dotted path relative to the containing frame, the arguments that the target type's `find` scope produced, or `null` when it added nothing. The adapter must join a crossed reference only where that scope admits the target, and count at an end or member only the records that the scope admits, so that a record the caller may not see reads as a missing one, also on a later hop with a `null` scope; a condition without `__scope` reads stored data, as server conditions and unmarked clients on unscoped chains do. `createQueryPlan(models, entity, args, { operators, referenceEmptiness: true })` lifts each filled `__scope` into the predicate's `scopes`, planning each scope on its target type, and throws `DETACHED_CLIENT_FILTER` (500) for a `__scope` that lacks a path; its `referenceEmptiness` option defaults to `true` when `operators` is omitted and to `false` otherwise. See [references and collections](https://simtlix.github.io/simfinity.js/guide/queries.html#references-and-collections).
26
26
 
27
27
  An adapter opts into collection frames with `queryFeatures.quantifiers: true` and `referenceEmptiness: true`; omission/null means false and invalid combinations give `INVALID_QUERY_FEATURES`. Every generated schema exposes `QLQuantifier`, `QLQuantifiedGroup`, group `ANY`/`ALL`/`NONE` lists and relation `quantifier`, with request-time capability gates. Collection shorthand becomes correlated `ANY`, with one child per group/prefix and no duplicated roots; explicit `ALL`/`NONE` use the same element-relative frame. Client scope keys are relative to their containing frame and apply `find` visibility before quantification; server conditions range over stored elements. `get_by_id` scopes are trusted, so `NE`, `NIN` and `EQ null` collection terms no longer admit childless records.
28
28
 
@@ -34,10 +34,16 @@ When a runtime first creates a schema, it binds each GraphQL object type whose n
34
34
 
35
35
  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. Virtual fields (`extensions: { virtual: true }` or `{ virtual: { mcp: 'tool' | 'select' | false } }`, with the field's own `resolve`) are never stored, get no generated input and no filter argument, and resolve wherever their type appears; `createSchema()` rejects an invalid declaration, a virtual field without its own resolver or with `relation`, `unique: true`, `validations`, `queryable: true` or `readOnly: false`, and a virtual identity, index, state-machine `state` or `connectionField` field (`INVALID_MODEL`, 400). A client filter, sort, `groupId` or fact path through a virtual field fails with `FORBIDDEN_FILTER_PATH` (403), and one that middleware or a scope adds fails with `INVALID_MODEL` (500). A `readOnly` field with arguments and its own resolver that is not virtual, a reference or a collection logs a `Configuration issue` warning once per runtime. Generated inputs that would have no fields are left out with a `Configuration issue` warning, with the collection operations, embedded fields and mutations that would take them; a non-null embedded field whose embedded type has no writable fields throws `INVALID_MODEL` (400) when generated mutations reach its owner. An application type named `FieldExtensionsType` or `RelationType` makes Simfinity rename its introspection metadata types to `SimfinityFieldExtensionsType` and `SimfinityRelationType` when it is in the process's first Simfinity schema. That schema fixes the names: in a later `createSchema()`, an application type with one of the current names, the original ones or the fallback names after a rename, throws `RESERVED_TYPE_NAME` (500), before `adapter.prepare()` when it is an included endpoint type, a registered mutation's input or output type, an output type they reach or a field argument's type, and when the schema is constructed otherwise, such as for a type inside an input object's fields.
36
36
 
37
- 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. Custom queries run without a transaction: their callback receives `(input, context)` and opens one through the engine's API when it needs it. `createSchema()` takes an optional fourth argument, an options object whose only key, `includedCustomQueries`, selects custom queries by name; any other value or key throws `INVALID_SCHEMA_OPTIONS` (500) before `adapter.prepare()`. A schema without mutation fields has no `Mutation` root. An adapter can define `stateValue` when its persisted enum representation differs from GraphQL enum names.
37
+ 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. Custom queries run without a transaction: their callback receives `(input, context)` and opens one through the engine's API when it needs it. `createSchema()` takes an optional fourth argument, an options object whose key `includedCustomQueries` selects custom queries by name and whose key `transaction: { name?, types }` adds the generated transaction mutation; any other value or key throws `INVALID_SCHEMA_OPTIONS` (500) before `adapter.prepare()`. An invalid `transaction` option raises the same code, after the models are prepared when the field name or a derived type name collides with the built schema, or when no operation is left in it. A schema without mutation fields has no `Mutation` root. An adapter can define `stateValue` when its persisted enum representation differs from GraphQL enum names.
38
38
 
39
39
  Importing the runtime installs Simfinity's `__Field.extensions` introspection field once per GraphQL module instance; its `readOnly` flag reads `true` for virtual fields. Multiple runtimes and driver packages can be imported together without cloning schemas or duplicating introspection types.
40
40
 
41
+ ## Lifecycle helpers and the transaction mutation
42
+
43
+ These APIs are available since 3.16.0. `updateObject(typeName, input, session?, context?)`, `deleteObject(typeName, { id, version? }, session?, context?)` and `transitionObject(typeName, actionName, input, session?, context?)` are `Runtime` methods, so SQL runtimes inherit them. They validate and copy their input (declared fields, internal enum values, record keys as strings), run the update, delete and state action lifecycle in an owned transaction or in the active session supplied, skip GraphQL coercion, field authorization and the root record's global middleware like `saveObject()`, and never commit, retry or abort a supplied session themselves. See the [server-side operations guide](https://simtlix.github.io/simfinity.js/guide/server-side-operations.html#update-delete-and-transition-from-code).
44
+
45
+ `createSchema(…, { transaction: { name?, types } })` adds one mutation field that runs a list of the generated mutations of `types` in one database transaction, with `$K` references to earlier results, the authorization checks of each operation's root and the position of a failing operation in `extensions.operationIndex` and `extensions.operation`. An adapter enables it by implementing the optional, synchronous `assertTransactionModels(models)`, which core calls on each invocation before the session opens with the models the transaction may write; it throws when they cannot share one session, and a promise it returns fails with `TRANSACTION_UNSUPPORTED`. The `withTransaction` of such an adapter must hand its body an object session. See the [mutations guide](https://simtlix.github.io/simfinity.js/guide/mutations.html#run-several-mutations-in-one-transaction).
46
+
41
47
  Controller `onSaveCommitted`, `onUpdateCommitted`, `onDeleteCommitted` and `onTransitionCommitted` callbacks use the optional `DatabaseAdapter.enqueueAfterCommit(session, run)` capability. It accepts jobs only for an active owned attempt, discards aborted/retried queues, and drains a confirmed queue serially after cleanup is attempted. Core preserves existing transactional hooks and exposes per-runtime `configureAfterCommit({ onError })` for operational reporting. Unsupported adapters reject configured committed callbacks with `UNSUPPORTED_AFTER_COMMIT`. External commit ownership is not inferred. See [callback guarantees and limits](https://simtlix.github.io/simfinity.js/guide/controllers.html#after-commit-callbacks).
42
48
 
43
49
  ## Shared metadata and helpers
@@ -78,3 +84,5 @@ pinned to the facade's exact version, including under pnpm or Yarn PnP. A late
78
84
  call fails without deleting installed metadata. Migrate clients selecting
79
85
  `__Field.extensions` before opting out. See the [descriptor reference](https://simtlix.github.io/simfinity.js/reference/client-descriptor.html)
80
86
  for the format, protected HTTP access and composition limits.
87
+
88
+ `queryFeatures.valuePath: true` opts an adapter into stored-field comparisons and requires `referenceEmptiness: true`; only true enables it. `valuePath` is nullable on all three generated filter inputs and is mutually exclusive with a supplied `value` (including null). Core validates both operand paths, their logical types, visibility and collection correlation, and preserves outer origins internally. `createQueryPlan` adds `operand?: QueryOperand` with `{ up, path, joins? }` and a null value placeholder; same-frame scope unions live in predicate `joins`, ancestor scopes in operand `joins`. Literal nodes stay unchanged. `describeModels` retains additive `enumName` and effective `temporalName` metadata. See [the comparison rules and limits](https://simtlix.github.io/simfinity.js/guide/queries.html#compare-stored-fields) and [compiler contract](https://simtlix.github.io/simfinity.js/guide/sql-plugins.html#query-features).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.15.0",
3
+ "version": "3.17.0",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/auth/index.js CHANGED
@@ -48,6 +48,9 @@ import { isPolicyExpression, createRuleFromExpression, evaluateExpression } from
48
48
  import { collectQueryPathEntries, walkQueryPath } from '../query-plan.js';
49
49
  import { relationFieldOwners } from '../relation-owners.js';
50
50
  import { isThenable } from './thenable.js';
51
+ import {
52
+ hasOperationEntry, isGeneratedMutationRoot, OPERATION_ENTRY_PROTOCOL, recordOperationEntry,
53
+ } from './operation-entries.js';
51
54
  import {
52
55
  resolvePath,
53
56
  requireAuth,
@@ -286,6 +289,10 @@ const authorizeQueryPaths = async (permissions, defaultPolicy, schema, query, ar
286
289
  /**
287
290
  * Creates a graphql-middleware compatible authorization middleware.
288
291
  *
292
+ * It denies a generated transaction field (`extensions.simfinityTransaction`) with ForbiddenError
293
+ * before resolving it, whatever its rules: it cannot apply the rules of each operation's root, which
294
+ * {@link createAuthPlugin} applies.
295
+ *
289
296
  * @deprecated Use {@link createAuthPlugin} instead. `applyMiddleware` from graphql-middleware
290
297
  * can cause duplicate-type errors when the schema contains custom introspection extensions.
291
298
  *
@@ -314,9 +321,16 @@ export const createAuthMiddleware = (permissions, options = {}) => {
314
321
 
315
322
  log(`Checking ${typeName}.${fieldName}`);
316
323
 
324
+ const field = info.parentType?.getFields?.()[fieldName];
325
+ // The transaction field runs other roots, whose rules this middleware never sees.
326
+ if (field?.extensions?.simfinityTransaction != null) {
327
+ log(`The middleware cannot authorize the operations of ${typeName}.${fieldName}`);
328
+ throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
329
+ }
330
+
317
331
  // Get rules for this field
318
332
  const rules = getFieldRules(permissions, typeName, fieldName);
319
- const query = info.parentType?.getFields?.()[fieldName]?.extensions?.simfinityQuery;
333
+ const query = field?.extensions?.simfinityQuery;
320
334
 
321
335
  // If no rules found, apply default policy
322
336
  if (rules === null) {
@@ -357,6 +371,7 @@ export const createAuthMiddleware = (permissions, options = {}) => {
357
371
  * It returns the same function as {@link createAuthMiddleware}. graphql-middleware applies a
358
372
  * function middleware to every field, so `'*'` rules and the default policy also apply to fields
359
373
  * the permission map does not name. A map of only the named fields would leave the others open.
374
+ * It denies a generated transaction field, as {@link createAuthMiddleware} does.
360
375
  *
361
376
  * @deprecated Use {@link createAuthPlugin} instead. `applyMiddleware` from graphql-middleware
362
377
  * can cause duplicate-type errors when the schema contains custom introspection extensions.
@@ -377,6 +392,12 @@ export const createFieldMiddleware = (permissions, options = {}) => createAuthMi
377
392
  * `onSchemaChange` throws `TypeError` before wrapping any field when a schema type was created by
378
393
  * another copy of the graphql module, instead of leaving that type's fields unprotected.
379
394
  *
395
+ * On each generated mutation root it wraps, in whatever type the field sits, the plugin also records
396
+ * an internal entry with the same rules, default policy and deferral, which the generated transaction
397
+ * field runs for each operation on that root. A root whose marker cannot take the entry (a frozen or
398
+ * otherwise non-extensible `extensions.simfinityMutation`) is still wrapped, and the transaction field
399
+ * refuses its operations with FORBIDDEN.
400
+ *
380
401
  * @param {PermissionSchema} permissions - The permission schema object
381
402
  * @param {AuthMiddlewareOptions} [options={}] - Plugin options
382
403
  * @returns {Object} An Envelop plugin with an `onSchemaChange` hook
@@ -413,10 +434,17 @@ export const createAuthPlugin = (permissions, options = {}) => {
413
434
 
414
435
  const log = debug ? console.log.bind(console, '[auth]') : () => {};
415
436
  const processedSchemas = new WeakSet();
416
- // Schemas built from the same types share their field objects, so each field is wrapped once.
437
+ // Schemas built from the same types share their field objects, so each field is wrapped once. Each
438
+ // wrapped field keeps `{ resolver, check, key }`: its wrapper, and the check and permission key an
439
+ // operation entry needs.
417
440
  const wrappers = new WeakMap();
441
+ // Identifies this instance's entries on the generated mutation roots.
442
+ const instanceToken = Symbol('simfinity auth plugin instance');
418
443
  let warnedAboutQueryRoot = false;
419
444
 
445
+ // In a schema that only other auth plugin instances processed, their rules apply, not these.
446
+ const isDeferred = (schema) => authorizedSchemas.has(schema) && !processedSchemas.has(schema);
447
+
420
448
  const warnAboutQueryRoot = (schema) => {
421
449
  if (warnedAboutQueryRoot || !Object.hasOwn(permissions, 'Query') || schema.getType('Query')) return;
422
450
  const queryRoot = schema.getQueryType()?.name;
@@ -425,27 +453,18 @@ export const createAuthPlugin = (permissions, options = {}) => {
425
453
  console.warn(`[auth] Permissions for type "Query" match no type in the schema. Its query root is named "${queryRoot}"; did you mean "${queryRoot}"?`);
426
454
  };
427
455
 
428
- const createFieldResolver = (schema, typeName, fieldName, rules, query, originalResolve) => {
429
- const key = `${typeName}.${fieldName}`;
456
+ // A field's rules, or the default policy when it has none, as a check that calls `grant` once access
457
+ // is granted and returns its result. A denial is a rejected ForbiddenError and an error a rule throws
458
+ // is rejected unchanged. Rules run in order and synchronously until one returns a promise.
459
+ const createFieldCheck = (key, rules) => {
430
460
  const denied = () => Promise.reject(new ForbiddenError(`Access denied to ${key}`));
431
461
 
432
- // Errors keep reaching GraphQL as rejections, as they did when the whole check was async.
433
- const resolveField = (parent, args, ctx, info) => {
434
- try {
435
- return originalResolve(parent, args, ctx, info);
436
- } catch (error) {
437
- return Promise.reject(error);
438
- }
439
- };
440
-
441
- const grant = (parent, args, ctx, info) => {
442
- if (!query) return resolveField(parent, args, ctx, info);
443
- return authorizeQueryPaths(permissions, defaultPolicy, info?.schema ?? schema, query, args, ctx, info)
444
- .then(() => resolveField(parent, args, ctx, info));
462
+ const deny = () => {
463
+ if (debug) log(`Rule denied access to ${key}`);
464
+ return denied();
445
465
  };
446
466
 
447
- // Rules run in order and synchronously until one returns a promise.
448
- const runRules = (index, parent, args, ctx, info) => {
467
+ const runRules = (index, parent, args, ctx, info, grant) => {
449
468
  for (let current = index; current < rules.length; current++) {
450
469
  if (debug) log(`Executing rule for ${key}`);
451
470
  let result;
@@ -456,7 +475,7 @@ export const createAuthPlugin = (permissions, options = {}) => {
456
475
  }
457
476
  if (isThenable(result)) {
458
477
  return Promise.resolve(result).then(value => (
459
- value === true || value === undefined ? runRules(current + 1, parent, args, ctx, info) : deny()
478
+ value === true || value === undefined ? runRules(current + 1, parent, args, ctx, info, grant) : deny()
460
479
  ));
461
480
  }
462
481
  if (result !== true && result !== undefined) return deny();
@@ -465,23 +484,38 @@ export const createAuthPlugin = (permissions, options = {}) => {
465
484
  return grant(parent, args, ctx, info);
466
485
  };
467
486
 
468
- const deny = () => {
469
- if (debug) log(`Rule denied access to ${key}`);
470
- return denied();
471
- };
472
-
473
- const resolver = (parent, args, ctx, info) => {
474
- // In a schema that only other auth plugin instances processed, their rules apply, not these.
475
- if (info?.schema && authorizedSchemas.has(info.schema) && !processedSchemas.has(info.schema)) {
476
- return originalResolve(parent, args, ctx, info);
477
- }
487
+ return (parent, args, ctx, info, grant) => {
478
488
  if (debug) log(`Checking ${key}`);
479
489
 
480
490
  if (rules === null) {
481
491
  if (debug) log(`No rules for ${key}, applying default policy: ${defaultPolicy}`);
482
492
  return defaultPolicy === 'DENY' ? denied() : grant(parent, args, ctx, info);
483
493
  }
484
- return runRules(0, parent, args, ctx, info);
494
+ return runRules(0, parent, args, ctx, info, grant);
495
+ };
496
+ };
497
+
498
+ const createFieldResolver = (schema, check, query, originalResolve) => {
499
+ // Errors keep reaching GraphQL as rejections, as they did when the whole check was async.
500
+ const resolveField = (parent, args, ctx, info) => {
501
+ try {
502
+ return originalResolve(parent, args, ctx, info);
503
+ } catch (error) {
504
+ return Promise.reject(error);
505
+ }
506
+ };
507
+
508
+ const grant = (parent, args, ctx, info) => {
509
+ if (!query) return resolveField(parent, args, ctx, info);
510
+ return authorizeQueryPaths(permissions, defaultPolicy, info?.schema ?? schema, query, args, ctx, info)
511
+ .then(() => resolveField(parent, args, ctx, info));
512
+ };
513
+
514
+ const resolver = (parent, args, ctx, info) => {
515
+ if (info?.schema && isDeferred(info.schema)) {
516
+ return originalResolve(parent, args, ctx, info);
517
+ }
518
+ return check(parent, args, ctx, info, grant);
485
519
  };
486
520
  // The wrapper still reads through the runtime that generated a relation resolver, so a field
487
521
  // copied with it stays bound to that runtime.
@@ -490,6 +524,18 @@ export const createAuthPlugin = (permissions, options = {}) => {
490
524
  return resolver;
491
525
  };
492
526
 
527
+ // The entry of a generated mutation root: the check its wrapper runs, without resolving the root.
528
+ // The wrapper's grant also authorizes client query paths, but only fields with
529
+ // extensions.simfinityQuery have them, and generated mutation roots never do (runtime.js
530
+ // buildMutation), so for these roots granting resolves nothing more.
531
+ const createOperationEntry = (check) => Object.freeze({
532
+ protocol: OPERATION_ENTRY_PROTOCOL,
533
+ isDeferred,
534
+ async run(parent, args, ctx, info) {
535
+ await check(parent, args, ctx, info, () => undefined);
536
+ },
537
+ });
538
+
493
539
  const wrapSchemaResolvers = (schema) => {
494
540
  if (processedSchemas.has(schema)) return;
495
541
  validateConfiguration(permissions, defaultPolicy);
@@ -507,18 +553,33 @@ export const createAuthPlugin = (permissions, options = {}) => {
507
553
  const fields = type.getFields();
508
554
 
509
555
  for (const [fieldName, field] of Object.entries(fields)) {
510
- // Skip fields this plugin already wraps; a resolver replaced since then is wrapped again.
511
- const wrapper = wrappers.get(field);
512
- if (wrapper !== undefined && wrapper === field.resolve) continue;
556
+ // A transaction runs the entries of each operation's root, in this schema or a copy. A generated
557
+ // root gets one wherever this instance wraps it, also in a type that is not the mutation root
558
+ // here, since the field and its wrapper can be the mutation root of another schema.
559
+ const isOperationRoot = isGeneratedMutationRoot(field);
560
+
561
+ // Skip fields this plugin already wraps; a resolver replaced since then is wrapped again. A
562
+ // wrapped root whose marker lacks this instance's entry for its key gets it again.
563
+ const wrapped = wrappers.get(field);
564
+ if (wrapped !== undefined && wrapped.resolver === field.resolve) {
565
+ if (isOperationRoot && !hasOperationEntry(field, instanceToken, wrapped.key)) {
566
+ recordOperationEntry(field, instanceToken, wrapped.key, createOperationEntry(wrapped.check));
567
+ }
568
+ continue;
569
+ }
513
570
 
514
571
  const rules = getFieldRules(permissions, typeName, fieldName);
515
572
  const query = field.extensions?.simfinityQuery;
516
573
  // An allowed field with no rule and no client query paths has nothing to check.
517
574
  if (rules === null && defaultPolicy === 'ALLOW' && !query && !debug) continue;
518
575
 
519
- const resolver = createFieldResolver(schema, typeName, fieldName, rules, query, field.resolve || defaultFieldResolver);
576
+ const key = `${typeName}.${fieldName}`;
577
+ const check = createFieldCheck(key, rules);
578
+ const resolver = createFieldResolver(schema, check, query, field.resolve || defaultFieldResolver);
579
+ // The entry is recorded first, so a root this instance wraps always has one.
580
+ if (isOperationRoot) recordOperationEntry(field, instanceToken, key, createOperationEntry(check));
520
581
  field.resolve = resolver;
521
- wrappers.set(field, resolver);
582
+ wrappers.set(field, { resolver, check, key });
522
583
  }
523
584
  }
524
585
 
@@ -0,0 +1,111 @@
1
+ import { ForbiddenError } from './errors.js';
2
+
3
+ // Internal, and never exported from the package or its auth subpath. The authorization entries of the
4
+ // generated mutation roots (#151): each auth plugin instance that wraps a root records an entry on
5
+ // it, and the generated transaction field runs the entries of each operation's root, so the operation
6
+ // gets the checks a direct call to that root gets in the same schema.
7
+ //
8
+ // Entries live on the root field's extensions.simfinityMutation object, which keeps its identity in
9
+ // schema copies, including copies that rebuild the Mutation type. The key is a registered symbol, so
10
+ // a plugin from another copy of core records entries this copy finds. Each entry is
11
+ // `{ protocol: 1, isDeferred(schema), run(parent, args, ctx, info) }`: isDeferred is the instance's
12
+ // own test for a schema only other instances processed, and run applies the rules of one permission
13
+ // key, or the default policy, rejecting with ForbiddenError on a denial and with any error a rule
14
+ // throws.
15
+ //
16
+ // An entry belongs to a pair: an instance and the `Type.field` permission key it wrapped the root
17
+ // under, in whatever type the field sat, since a field wrapped where its type is not the mutation root
18
+ // keeps that wrapper when the type is the root of another schema. Recording a pair again replaces its
19
+ // entry and makes it the latest, as wrapping a field again makes that wrapper the outermost.
20
+ // authorizeOperation runs every entry of the marker, the latest first. For a generated schema and its
21
+ // copies that equals the wrappers a direct call runs, outermost first, so the first denial is the one
22
+ // a direct call reports. When several keys reach one marker (an alias of a root, a copy that renames
23
+ // the Mutation type), it runs the checks of all of them: never fewer than a direct call to any of
24
+ // them.
25
+
26
+ export const OPERATION_EVALUATORS = Symbol.for('simfinity.auth.operationEvaluators');
27
+ export const OPERATION_ENTRY_PROTOCOL = 1;
28
+
29
+ // The operations of the generated roots a transaction runs; registered mutations, marked
30
+ // custom_mutation, never are operations.
31
+ const GENERATED_OPERATIONS = new Set(['save', 'update', 'delete', 'state_changed']);
32
+
33
+ // Whether a field is a generated mutation root, by its extensions.simfinityMutation marker.
34
+ export const isGeneratedMutationRoot = (field) => {
35
+ const marker = field?.extensions?.simfinityMutation;
36
+ return marker !== null && typeof marker === 'object' && GENERATED_OPERATIONS.has(marker.operation);
37
+ };
38
+
39
+ // The entries Map of a generated root, created on first use in a non-enumerable property that cannot
40
+ // be replaced, or undefined when the marker has none and cannot take one (a frozen, sealed or
41
+ // non-extensible marker): the root's own wrapper still applies its rules, and authorizeOperation denies
42
+ // operations on it. Anything else under the key is an error, never overwritten.
43
+ const entriesOf = (field) => {
44
+ const marker = field.extensions.simfinityMutation;
45
+ if (!Object.hasOwn(marker, OPERATION_EVALUATORS)) {
46
+ if (!Object.isExtensible(marker)) return undefined;
47
+ Object.defineProperty(marker, OPERATION_EVALUATORS, { value: new Map() });
48
+ }
49
+ const entries = marker[OPERATION_EVALUATORS];
50
+ if (!(entries instanceof Map)) throw new TypeError('The authorization entries of a generated mutation root are not a Map');
51
+ return entries;
52
+ };
53
+
54
+ // The Map key of a pair, or undefined. Keys of another core copy may have another shape.
55
+ const findPair = (entries, instanceToken, permissionKey) => {
56
+ for (const pair of entries.keys()) {
57
+ if (pair?.instance === instanceToken && pair?.permissionKey === permissionKey) return pair;
58
+ }
59
+ return undefined;
60
+ };
61
+
62
+ // Records the entry of the instance `instanceToken` for the root under `permissionKey`, replacing
63
+ // that pair's earlier entry and making it the latest. A marker that cannot take entries records nothing.
64
+ export const recordOperationEntry = (field, instanceToken, permissionKey, entry) => {
65
+ const entries = entriesOf(field);
66
+ if (entries === undefined) return;
67
+ const pair = findPair(entries, instanceToken, permissionKey);
68
+ if (pair !== undefined) entries.delete(pair);
69
+ entries.set(Object.freeze({ instance: instanceToken, permissionKey }), entry);
70
+ };
71
+
72
+ // Whether the pair has an entry on the root.
73
+ export const hasOperationEntry = (field, instanceToken, permissionKey) => {
74
+ const entries = field.extensions.simfinityMutation[OPERATION_EVALUATORS];
75
+ return entries instanceof Map && findPair(entries, instanceToken, permissionKey) !== undefined;
76
+ };
77
+
78
+ /**
79
+ * Applies to an operation on the generated root `rootField` the checks a direct call to that root gets
80
+ * in `info.schema`: every entry on the root runs once, the latest first, unless its instance defers
81
+ * in that schema. The rules see `info` with the root's field name and return type; the root is a
82
+ * field of the same Mutation type as the field that runs the operation, `info.parentType`. The path,
83
+ * field nodes and operation stay the caller's. Fails closed: an entry of an unknown protocol, deferred
84
+ * or not, denies with ForbiddenError before any entry runs, and so does a marker that has no entries
85
+ * and cannot take them, since an instance may have wrapped the root without recording its entry. A
86
+ * root with no entries passes, as a root no instance wrapped does when called directly.
87
+ * @returns {Promise<void>}
88
+ */
89
+ export const authorizeOperation = async (rootField, parent, args, ctx, info) => {
90
+ const marker = rootField.extensions?.simfinityMutation;
91
+ if (marker === null || typeof marker !== 'object') return;
92
+ const key = `${info.parentType.name}.${rootField.name}`;
93
+ if (!Object.hasOwn(marker, OPERATION_EVALUATORS)) {
94
+ if (!Object.isExtensible(marker)) throw new ForbiddenError(`Access denied to ${key}`);
95
+ return;
96
+ }
97
+ const entries = marker[OPERATION_EVALUATORS];
98
+ if (!(entries instanceof Map)) throw new ForbiddenError(`Access denied to ${key}`);
99
+ const latestFirst = [...entries.values()].reverse();
100
+ if (latestFirst.some((entry) => entry?.protocol !== OPERATION_ENTRY_PROTOCOL)) {
101
+ throw new ForbiddenError(`Access denied to ${key}`);
102
+ }
103
+ // Permissions must see the identity of the root, just as when it is called directly.
104
+ const operationInfo = {
105
+ ...info, fieldName: rootField.name, parentType: info.parentType, returnType: rootField.type,
106
+ };
107
+ for (const entry of latestFirst) {
108
+ if (entry.isDeferred(info.schema)) continue;
109
+ await entry.run(parent, args, ctx, operationInfo);
110
+ }
111
+ };
@@ -21,6 +21,8 @@ const QLOperator = new GraphQLEnumType({
21
21
  IEQ: { value: 'IEQ' },
22
22
  IS_EMPTY: { value: 'IS_EMPTY' },
23
23
  IS_NOT_EMPTY: { value: 'IS_NOT_EMPTY' },
24
+ CONTAINS_ALL: { value: 'CONTAINS_ALL' },
25
+ IIN: { value: 'IIN' },
24
26
  },
25
27
  });
26
28
 
@@ -0,0 +1,71 @@
1
+ import { nestedOperationsExceededError } from './mutation-limits.js';
2
+
3
+ // Internal, and never exported from the package. The state of one generated transaction attempt (#151):
4
+ // the mutation limits it snapshotted at its start and its joint count of nested collection entries.
5
+ // A state is attached to the session of its attempt for as long as the attempt runs, so every nested
6
+ // entry that runs on that session counts against the same budget, wherever it started: the
7
+ // operations, their middleware, actions and controllers, and the lifecycle helpers and saveObject
8
+ // calls those controllers make. Nothing public reads it and it never travels in `context`.
9
+ //
10
+ // session -> the attachments it holds, oldest first, as `{ state, holds }`. The latest one is the
11
+ // session's state. Attaching the latest state again adds a hold instead of an entry, and a detach
12
+ // releases one hold, so an attachment lasts until the last caller that made it detaches, and the
13
+ // state it replaced is the session's state again once it is gone.
14
+ const attachments = new WeakMap();
15
+
16
+ const isUnlimited = (limit) => limit === null || limit === undefined;
17
+
18
+ // runtimeToken is the identity of the runtime that owns the attempt (see createRuntime), so that a
19
+ // session lent to another runtime finds no state.
20
+ export const createExecutionState = ({ runtimeToken, limits: { maxNestedOperations, maxTransactionOperations } }) => ({
21
+ runtimeToken,
22
+ limits: Object.freeze({ maxNestedOperations, maxTransactionOperations }),
23
+ nestedCount: 0,
24
+ });
25
+
26
+ // The same state may be attached to more than one session: when the adapter's withTransaction hands a
27
+ // body another object than the one it was given, the state follows it for the duration of the body.
28
+ export const attachExecutionState = (session, state) => {
29
+ let held = attachments.get(session);
30
+ if (held === undefined) {
31
+ held = [];
32
+ attachments.set(session, held);
33
+ }
34
+ const latest = held[held.length - 1];
35
+ if (latest?.state === state) latest.holds += 1;
36
+ else held.push({ state, holds: 1 });
37
+ };
38
+
39
+ // Releases one hold of the latest attachment of that state; a session without it is left as it is.
40
+ export const detachExecutionState = (session, state) => {
41
+ const held = attachments.get(session);
42
+ if (held === undefined) return;
43
+ for (let index = held.length - 1; index >= 0; index -= 1) {
44
+ if (held[index].state === state) {
45
+ held[index].holds -= 1;
46
+ if (held[index].holds === 0) held.splice(index, 1);
47
+ break;
48
+ }
49
+ }
50
+ if (held.length === 0) attachments.delete(session);
51
+ };
52
+
53
+ // WeakMap.get answers undefined for a key that is not an object, so a missing session finds no state.
54
+ export const executionStateOf = (session, runtimeToken) => {
55
+ const held = attachments.get(session);
56
+ const state = held?.[held.length - 1].state;
57
+ return state?.runtimeToken === runtimeToken ? state : undefined;
58
+ };
59
+
60
+ // One call per item that executeItemFunction iterates, before the item runs.
61
+ export const countNestedEntry = (state) => {
62
+ state.nestedCount += 1;
63
+ const limit = state.limits.maxNestedOperations;
64
+ if (!isUnlimited(limit) && state.nestedCount > limit) throw nestedOperationsExceededError(limit);
65
+ };
66
+
67
+ // The nested entries the attempt may still run, or null when it has no limit.
68
+ export const remainingNestedBudget = (state) => {
69
+ const limit = state.limits.maxNestedOperations;
70
+ return isUnlimited(limit) ? null : Math.max(0, limit - state.nestedCount);
71
+ };
package/src/metadata.js CHANGED
@@ -54,7 +54,7 @@ const scalarDescription = (type) => {
54
54
  if (storedValues.has(stored) && !Object.is(storedValues.get(stored), entry.value)) invalid(`Enum storage collision in ${type.name}: distinct internal values serialize to ${stored}`);
55
55
  storedValues.set(stored, entry.value);
56
56
  }
57
- return { scalar: 'Enum', values: [...storedValues.keys()], enumValues: type.getValues().map(({ name, value }) => ({ name, value })) };
57
+ return { scalar: 'Enum', enumName: type.name, values: [...storedValues.keys()], enumValues: type.getValues().map(({ name, value }) => ({ name, value })) };
58
58
  }
59
59
  // The walk stops on a type that still has a base only where the chain repeats.
60
60
  if (rootScalar(type).baseScalarType) invalid(`Cyclic base scalar ${type.name}`);
@@ -63,7 +63,7 @@ const scalarDescription = (type) => {
63
63
  if (!['String', 'ID', 'Int', 'Float', 'Boolean', 'DateTime'].includes(scalar)) {
64
64
  invalid(`Unsupported scalar ${type.name}: declare a supported baseScalarType`);
65
65
  }
66
- return { scalar };
66
+ return { scalar, ...(scalar === 'DateTime' ? { temporalName: name } : {}) };
67
67
  };
68
68
 
69
69
  // The version field of a protected entity, described the same way whether the type carries Simfinity's
@@ -1,24 +1,54 @@
1
1
  import SimfinityError from './errors/simfinity.error.js';
2
2
 
3
+ const OPTION_KEYS = ['maxNestedOperations', 'maxTransactionOperations'];
4
+ const DEFAULT_MAX_TRANSACTION_OPERATIONS = 100;
5
+
3
6
  // Process-wide, like the query limits. null leaves nested collection operations unlimited.
4
7
  let maxNestedOperations = null;
8
+ let maxTransactionOperations = DEFAULT_MAX_TRANSACTION_OPERATIONS;
5
9
 
10
+ // Each call replaces both values: an omitted or undefined option restores its default.
6
11
  export const configureMutationLimits = (options = {}) => {
7
12
  if (!options || typeof options !== 'object' || Array.isArray(options)
8
13
  || ![Object.prototype, null].includes(Object.getPrototypeOf(options))) {
9
14
  throw new SimfinityError('Mutation limit options must be a plain object', 'INVALID_MUTATION_LIMITS', 400);
10
15
  }
11
- // A misspelled option must not silently reset the limit to unlimited.
12
- const unknown = Reflect.ownKeys(options).find((key) => key !== 'maxNestedOperations');
16
+ // A misspelled option must not silently reset a limit to its default.
17
+ const unknown = Reflect.ownKeys(options).find((key) => !OPTION_KEYS.includes(key));
13
18
  if (unknown !== undefined) {
14
- throw new SimfinityError(`Unknown mutation limit option ${String(unknown)}; use maxNestedOperations`,
15
- 'INVALID_MUTATION_LIMITS', 400);
19
+ throw new SimfinityError(
20
+ `Unknown mutation limit option ${String(unknown)}; use maxNestedOperations or maxTransactionOperations`,
21
+ 'INVALID_MUTATION_LIMITS',
22
+ 400,
23
+ );
16
24
  }
17
- const { maxNestedOperations: value = null } = options;
18
- if (value !== null && (!Number.isSafeInteger(value) || value < 0)) {
25
+ const {
26
+ maxNestedOperations: nested = null,
27
+ maxTransactionOperations: transaction = DEFAULT_MAX_TRANSACTION_OPERATIONS,
28
+ } = options;
29
+ if (nested !== null && (!Number.isSafeInteger(nested) || nested < 0)) {
19
30
  throw new SimfinityError('maxNestedOperations must be null or a non-negative safe integer', 'INVALID_MUTATION_LIMITS', 400);
20
31
  }
21
- maxNestedOperations = value;
32
+ if (!Number.isSafeInteger(transaction) || transaction < 1) {
33
+ throw new SimfinityError('maxTransactionOperations must be a positive safe integer', 'INVALID_MUTATION_LIMITS', 400);
34
+ }
35
+ // Validate everything first: a rejected call keeps both current limits.
36
+ maxNestedOperations = nested;
37
+ maxTransactionOperations = transaction;
22
38
  };
23
39
 
24
40
  export const getMaxNestedOperations = () => maxNestedOperations;
41
+
42
+ export const getMaxTransactionOperations = () => maxTransactionOperations;
43
+
44
+ // Internal, like the getters: the package exports only configureMutationLimits. The error of a mutation
45
+ // past maxNestedOperations, which the preflights and a transaction attempt's counter (execution-state.js)
46
+ // raise alike.
47
+ export const nestedOperationsExceededError = (limit) => new SimfinityError(
48
+ `A mutation may perform at most ${limit} nested collection operations`, 'NESTED_OPERATIONS_EXCEEDED', 400,
49
+ );
50
+
51
+ // The error of a generated transaction (#151) whose list is longer than maxTransactionOperations.
52
+ export const transactionOperationsExceededError = (limit) => new SimfinityError(
53
+ `A transaction may run at most ${limit} operations`, 'TRANSACTION_OPERATIONS_EXCEEDED', 400,
54
+ );