@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 +12 -4
- package/package.json +1 -1
- package/src/auth/index.js +97 -36
- package/src/auth/operation-entries.js +111 -0
- package/src/const/QLOperator.js +2 -0
- package/src/execution-state.js +71 -0
- package/src/metadata.js +2 -2
- package/src/mutation-limits.js +37 -7
- package/src/operation-input.js +345 -0
- package/src/query-comparisons.js +54 -0
- package/src/query-frames.js +160 -26
- package/src/query-operators.js +98 -10
- package/src/query-plan.js +94 -29
- package/src/runtime.js +723 -77
- package/src/schema-registrations.js +6 -1
- package/src/transaction-references.js +259 -0
- package/src/transaction-schema.js +163 -0
- package/types/index.d.ts +177 -19
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
|
|
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`,
|
|
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
|
|
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
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 =
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
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
|
+
};
|
package/src/const/QLOperator.js
CHANGED
|
@@ -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
|
package/src/mutation-limits.js
CHANGED
|
@@ -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
|
|
12
|
-
const unknown = Reflect.ownKeys(options).find((key) => key
|
|
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(
|
|
15
|
-
|
|
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 {
|
|
18
|
-
|
|
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
|
-
|
|
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
|
+
);
|