@simtlix/simfinity-core 3.13.0 → 3.15.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
@@ -16,13 +16,17 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
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, reference-frame and nested-operation limits.
19
+ - `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size, compiled-frame, frame-nesting and nested-operation limits.
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. Operators that `QLOperator` does not list still reach the adapter. 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 from the queried record, 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` comes from middleware or a scope and reads stored data. `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. `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).
26
+
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
+
29
+ `createQueryPlan()` enables quantifiers by default without `operators`; with `operators`, declare capabilities explicitly. Its `QueryPredicate` includes `quantified`, element `scope`/`where`, scoped-reference `joins` and aggregate `anchor`. Compilers must honour these members and fail closed on incomplete metadata. `configureQueryLimits` adds `maxFrameNesting` (default 3), while `maxReferenceFrames` (10) counts compiled client units, even when unmarked, after all client 400 checks. Named views preserve separate client-argument and `where` budgets of that size. Every adapter rejects list sorts through collections, including count mode. See the [plugin contract](https://simtlix.github.io/simfinity.js/guide/sql-plugins.html#query-features) and [upgrade notes](https://simtlix.github.io/simfinity.js/resources/compatibility.html#collection-frames).
26
30
 
27
31
  Entity types, those registered with `connect()` and the targets of non-embedded relations, resolve `id` to `_id`, falling back to `id`, whatever the `createSchema()` allowlists include; types used only as embedded values keep their declared `id`. Embedded copies of an entity type resolve the same way, so they return their declared `id` unless the adapter stores an `_id` for them, as MongoDB does for embedded list items. The resolver is set on the type object, so it applies in every runtime that shares the type. `createSchema()` marks these registrations with `storedIdentity: true` before calling `adapter.prepare()`.
28
32
 
@@ -34,6 +38,8 @@ Generated and custom mutations run through `adapter.withTransaction`. Retried at
34
38
 
35
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.
36
40
 
41
+ 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
+
37
43
  ## Shared metadata and helpers
38
44
 
39
45
  `describeModels([{ gqltype, endpoint }])` discovers the persistent entity graph from `GraphQLObjectType` definitions and `extensions.relation`, leaving virtual fields out and describing no type that only virtual fields reach. Types registered with `endpoint: false` are still included when referenced. Reciprocal one-to-many definitions resolve to one child reference; explicit linking entities represent many-to-many relationships.
@@ -42,7 +48,7 @@ Importing the runtime installs Simfinity's `__Field.extensions` introspection fi
42
48
 
43
49
  `isVirtualField(field)` tells whether a GraphQL field is virtual, and `virtualFieldOptions(field)` returns its normalized options, `{ mcp }`, or `null` for any other field. Adapters that build storage from GraphQL types use them to skip virtual fields.
44
50
 
45
- The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `UnauthenticatedError` and `ForbiddenError` (the same classes as `auth.UnauthenticatedError` and `auth.ForbiddenError`), `buildErrorFormatter`, `configureQueryLimits`, `configureMutationLimits`, `QLOperator`, `QLSort`, and `QLValue`. `buildErrorFormatter` returns a `GraphQLError` whose `originalError` is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
51
+ The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `UnauthenticatedError` and `ForbiddenError` (the same classes as `auth.UnauthenticatedError` and `auth.ForbiddenError`), `buildErrorFormatter`, `configureQueryLimits`, `configureMutationLimits`, `QLOperator`, `QLQuantifier`, `QLSort`, and `QLValue`. `buildErrorFormatter` returns a `GraphQLError` whose `originalError` is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
46
52
 
47
53
  The same helpers are available from the `@simtlix/simfinity-core/auth`, `/auth/errors`, `/auth/expressions`, `/auth/rules`, `/plugins`, `/scalars` and `/validators` subpaths, each with TypeScript declarations whose types match the root namespaces. `moduleResolution` `node16`, `nodenext` and `bundler` read them through the export map, and `node10` through `typesVersions`. The `internal/*` subpaths are not public API and have no declarations.
48
54
 
@@ -53,3 +59,22 @@ The same helpers are available from the `@simtlix/simfinity-core/auth`, `/auth/e
53
59
  An adapter advertises `writeFeatures: { optimisticConcurrency: true }`; `createRuntime()` snapshots/freezes this declaration and rejects invalid shapes with `INVALID_WRITE_FEATURES` (500). A protected schema rejects an adapter without support. `update(model, id, changes, session, { expectedVersion })` must atomically match ID/version, apply changes and set `expectedVersion + 1`, returning the updated record; `delete(model, id, session, { expectedVersion })` must atomically match both and return the deleted record. An update at the maximum skips the write. After no match, read the same cast key in the same session: return `null` if absent, report `VERSION_CONFLICT` if stale or `VERSION_EXHAUSTED` for an equal maximum on update, otherwise return `null`. MongoDB missing/null versions count as 1. Without `expectedVersion`, existing writes and call paths remain unchanged.
54
60
 
55
61
  Support also requires `find(model, type, args, session, { requiredId, context })` to honor the session, required key and context for scoped visibility checks. Wrappers must forward every argument, including options. Core's update result diagnostic requires `expected + 1` and advancement beyond its immutable pre-read version; delete results must carry the expected version. These diagnostics raise `DATABASE_ERROR` for inconsistent results but cannot prove arbitrary wrappers kept the atomic predicate. They retain supplied-session ownership; adapters must preserve their own guarded-write abort semantics. The `get_by_id` scope defers stale/exhausted errors for hidden callers until write hooks authorize the operation, but malformed stored versions can fail before hooks. Protected writes always perform their required pre-read, regardless of `readByIdIsInert`.
62
+
63
+ ## Client metadata
64
+
65
+ Since 3.15.0, install the optional `@simtlix/simfinity-descriptor` package,
66
+ aligned with your Simfinity version. It exports `buildClientDescriptor(schema)`
67
+ and `createClientDescriptorHandler(schema, { authorize: 'public' })`. The builder
68
+ describes the exact `createSchema()` result; the explicitly mounted handler can
69
+ instead use an application access function. These functions are not facade/core
70
+ exports. The descriptor does not evaluate dynamic permissions or serialize
71
+ arbitrary validators.
72
+
73
+ Legacy introspection stays enabled by default in 3.x. To opt out, import
74
+ `configureIntrospection` from `@simtlix/simfinity-core/introspection` in a separate
75
+ bootstrap module and call it with `{ fieldExtensions: false }` **before importing
76
+ the runtime or facades**. That direct import requires core as a direct dependency
77
+ pinned to the facade's exact version, including under pnpm or Yarn PnP. A late
78
+ call fails without deleting installed metadata. Migrate clients selecting
79
+ `__Field.extensions` before opting out. See the [descriptor reference](https://simtlix.github.io/simfinity.js/reference/client-descriptor.html)
80
+ for the format, protected HTTP access and composition limits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.13.0",
3
+ "version": "3.15.0",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -27,6 +27,9 @@
27
27
  ],
28
28
  "validators": [
29
29
  "types/validators.d.ts"
30
+ ],
31
+ "introspection": [
32
+ "types/introspection.d.ts"
30
33
  ]
31
34
  }
32
35
  },
@@ -52,6 +55,7 @@
52
55
  "default": "./src/auth/rules.js"
53
56
  },
54
57
  "./internal/object-id": "./src/auth/object-id.js",
58
+ "./internal/transaction-callbacks": "./src/transaction-callbacks.js",
55
59
  "./internal/query-operators": "./src/query-operators.js",
56
60
  "./internal/record-version": "./src/record-version.js",
57
61
  "./internal/relation-storage": "./src/relation-storage.js",
@@ -66,7 +70,13 @@
66
70
  "./validators": {
67
71
  "types": "./types/validators.d.ts",
68
72
  "default": "./src/validators.js"
69
- }
73
+ },
74
+ "./introspection": {
75
+ "types": "./types/introspection.d.ts",
76
+ "default": "./src/introspection-config.js"
77
+ },
78
+ "./internal/schema-registrations": "./src/schema-registrations.js",
79
+ "./internal/errors": "./src/errors/simfinity.error.js"
70
80
  },
71
81
  "files": [
72
82
  "src",
@@ -0,0 +1,83 @@
1
+ import SimfinityError from './errors/simfinity.error.js';
2
+
3
+ const callbackNames = {
4
+ save: 'onSaveCommitted', update: 'onUpdateCommitted', delete: 'onDeleteCommitted',
5
+ state_changed: 'onTransitionCommitted',
6
+ };
7
+
8
+ // Copy ordinary values without JSON coercion, preserving opaque custom scalar instances.
9
+ const snapshot = (value, copies = new Map()) => {
10
+ if (!value || typeof value !== 'object') return value;
11
+ if (copies.has(value)) return copies.get(value);
12
+ if (value instanceof Date) return new Date(value.getTime());
13
+ const prototype = Object.getPrototypeOf(value);
14
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return value;
15
+ const copy = Array.isArray(value) ? new Array(value.length) : Object.create(prototype);
16
+ copies.set(value, copy);
17
+ for (const [key, entry] of Object.entries(value)) {
18
+ Object.defineProperty(copy, key, { value: snapshot(entry, copies), writable: true, enumerable: true, configurable: true });
19
+ }
20
+ return copy;
21
+ };
22
+
23
+ const reportFailure = (typeName, operation, callbackName) => {
24
+ // Never log the record, request, or arbitrary error contents by default.
25
+ try { console.error('After-commit callback failed', { typeName, operation, callbackName }); } catch { /* Best effort. */ }
26
+ };
27
+
28
+ export const createAfterCommit = (adapter) => {
29
+ let onError;
30
+ const warned = new Set();
31
+ const configure = (options = {}) => {
32
+ if (!options || typeof options !== 'object' || Array.isArray(options)
33
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(options))
34
+ || Reflect.ownKeys(options).some((key) => key !== 'onError')
35
+ || (options.onError !== undefined && typeof options.onError !== 'function')) {
36
+ throw new SimfinityError('Invalid after-commit configuration', 'INVALID_AFTER_COMMIT_OPTIONS', 400);
37
+ }
38
+ onError = options.onError;
39
+ };
40
+ const validate = (controller) => {
41
+ for (const name of Object.values(callbackNames)) {
42
+ const callback = controller?.[name];
43
+ if (callback == null) continue;
44
+ if (typeof callback !== 'function') {
45
+ throw new SimfinityError(`${name} must be a function`, 'INVALID_CONTROLLER', 400);
46
+ }
47
+ if (typeof adapter.enqueueAfterCommit !== 'function') {
48
+ throw new SimfinityError('The adapter does not support after-commit callbacks', 'UNSUPPORTED_AFTER_COMMIT', 500);
49
+ }
50
+ }
51
+ };
52
+ const queue = ({ typeName, controller, operation, record, args, context, session, action }) => {
53
+ const callbackName = callbackNames[operation];
54
+ const callback = controller?.[callbackName];
55
+ if (!callback || record == null) return;
56
+ validate(controller);
57
+ const reporter = onError;
58
+ let payload;
59
+ const accepted = adapter.enqueueAfterCommit(session, async () => {
60
+ try {
61
+ let values = [payload.record, context];
62
+ if (operation === 'save') values = [payload.record, payload.args, context];
63
+ if (operation === 'state_changed') values = [payload.record, action, context];
64
+ await callback.apply(controller, values);
65
+ } catch (error) {
66
+ if (reporter) {
67
+ try {
68
+ await reporter(error, { committed: true, typeName, operation, callbackName, context });
69
+ return;
70
+ } catch { /* The reporter cannot fail the committed operation either. */ }
71
+ }
72
+ reportFailure(typeName, operation, callbackName);
73
+ }
74
+ });
75
+ if (accepted) {
76
+ payload = { record: snapshot(adapter.toObject(record)), args: operation === 'save' ? snapshot(args) : undefined };
77
+ } else if (!warned.has(typeName)) {
78
+ warned.add(typeName);
79
+ try { console.warn(`After-commit callbacks for ${typeName} skipped: this adapter does not own the transaction`); } catch { /* Best effort. */ }
80
+ }
81
+ };
82
+ return { configure, validate, queue };
83
+ };
@@ -0,0 +1,21 @@
1
+ import { GraphQLEnumType } from 'graphql';
2
+
3
+ const QLQuantifier = new GraphQLEnumType({
4
+ name: 'QLQuantifier',
5
+ values: {
6
+ ANY: { value: 'ANY' },
7
+ ALL: { value: 'ALL' },
8
+ NONE: { value: 'NONE' },
9
+ },
10
+ });
11
+
12
+ export const QUANTIFIED_LISTS = Object.freeze(QLQuantifier.getValues().map(({ value }) => value));
13
+ export const QUANTIFIERS = new Set(QUANTIFIED_LISTS);
14
+
15
+ // Shared by the collector and the quantified-group validator. `path` is allowed only on a
16
+ // quantified member; reserved metadata is interpreted by its owning phase.
17
+ export const GROUP_KEYS = new Set([
18
+ 'conditions', 'AND', 'OR', ...QUANTIFIED_LISTS, 'path', '__scope', '__implicit', '__anchor',
19
+ ]);
20
+
21
+ export default QLQuantifier;
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  } from './runtime.js';
11
11
  export { createQueryPlan, resolveModelPath } from './query-plan.js';
12
12
  export { default as QLOperator } from './const/QLOperator.js';
13
+ export { default as QLQuantifier } from './const/QLQuantifier.js';
13
14
  export { default as QLSort } from './const/QLSort.js';
14
15
  export { default as QLValue } from './const/QLValue.js';
15
16
  export { configureQueryLimits, paginationStages } from './query-limits.js';
@@ -0,0 +1,17 @@
1
+ import { __Field } from 'graphql';
2
+ import SimfinityError from './errors/simfinity.error.js';
3
+ import { introspectionState } from './introspection-state.js';
4
+
5
+ /** Configure before importing the runtime or either database facade. Never removes an extension. */
6
+ export const configureIntrospection = (options) => {
7
+ if (!options || ![Object.prototype, null].includes(Object.getPrototypeOf(options))
8
+ || Reflect.ownKeys(options).length !== 1 || options.fieldExtensions !== false) {
9
+ throw new SimfinityError('configureIntrospection requires only { fieldExtensions: false }',
10
+ 'INVALID_INTROSPECTION_CONFIGURATION', 500);
11
+ }
12
+ if (__Field.getFields().extensions) {
13
+ throw new SimfinityError('Configure fieldExtensions before importing the Simfinity runtime; __Field.extensions is already installed',
14
+ 'INVALID_INTROSPECTION_CONFIGURATION', 500);
15
+ }
16
+ introspectionState().disabled = true;
17
+ };
@@ -0,0 +1,10 @@
1
+ import { __Field } from 'graphql';
2
+
3
+ // Shared only by cooperating core evaluations using the same GraphQL peer. No schema/type mutation.
4
+ const KEY = Symbol.for('simfinity.introspectionConfiguration.v1');
5
+ const states = globalThis[KEY] || new WeakMap();
6
+ if (!globalThis[KEY]) Object.defineProperty(globalThis, KEY, { value: states });
7
+ export const introspectionState = () => {
8
+ if (!states.has(__Field)) states.set(__Field, { disabled: false });
9
+ return states.get(__Field);
10
+ };
@@ -5,6 +5,7 @@ import {
5
5
  __Field,
6
6
  } from 'graphql';
7
7
  import { isVirtualField } from './relation-storage.js';
8
+ import { introspectionState } from './introspection-state.js';
8
9
 
9
10
  // The object types of `__Field.extensions`: FieldExtensionsType and the RelationType of its
10
11
  // `relation` field, under the given names.
@@ -39,7 +40,7 @@ const fieldMetadataOf = (field) => (isVirtualField(field)
39
40
  // getFields() supports both lazy and already materialized introspection fields.
40
41
  // Reuse the shared field on repeated module evaluation to preserve type identity.
41
42
  const introspectionFields = __Field.getFields();
42
- if (!introspectionFields.extensions) {
43
+ if (!introspectionState().disabled && !introspectionFields.extensions) {
43
44
  introspectionFields.extensions = {
44
45
  type: createFieldMetadataType('FieldExtensionsType', 'RelationType'),
45
46
  name: 'extensions',
@@ -56,14 +57,15 @@ const extensionsField = () => __Field.getFields().extensions;
56
57
 
57
58
  /** The metadata types `__Field.extensions` returns now: [FieldExtensionsType, RelationType]. */
58
59
  export const fieldMetadataTypes = () => {
60
+ if (introspectionState().disabled) return [];
59
61
  const extensionsType = extensionsField().type;
60
62
  return [extensionsType, extensionsType.getFields().relation.type];
61
63
  };
62
64
 
63
- export const fieldMetadataNamesFixed = () => extensionsField()[NAMES_FIXED] === true;
65
+ export const fieldMetadataNamesFixed = () => extensionsField()?.[NAMES_FIXED] === true;
64
66
 
65
67
  export const fixFieldMetadataNames = () => {
66
- extensionsField()[NAMES_FIXED] = true;
68
+ if (!introspectionState().disabled) extensionsField()[NAMES_FIXED] = true;
67
69
  };
68
70
 
69
71
  // Every schema constructed afterwards, in the whole process, contains the metadata types under the
@@ -45,7 +45,7 @@ const LISTS = { embeddedList: 'an embedded list', collection: 'a collection' };
45
45
  // collection names its `target` type, and a collection the child's storage column that links it,
46
46
  // `connectionField`, derived as MongoDB derives it; without a declared connectionField a collection
47
47
  // reads through its own field name.
48
- const graphqlStep = (name, field) => {
48
+ export const graphqlStep = (name, field) => {
49
49
  const type = getNamedType(field.type);
50
50
  const list = !!getListShape(field.type);
51
51
  if (isInterfaceType(type) || isUnionType(type)) return { name, kind: 'abstract' };
@@ -69,7 +69,7 @@ const graphqlMembers = (type) => Object.entries(type.getFields())
69
69
  // The same classification of a describeModels field description: inferred inverse references are ID
70
70
  // scalars, as GraphQL exposes them. Descriptions hold no interface or union fields, and describeModels
71
71
  // has already normalized a collection's connectionField to the child's storage column.
72
- const modelStep = (field) => {
72
+ export const modelStep = (field) => {
73
73
  if (field.kind === 'embedded') return { name: field.name, kind: field.list ? 'embeddedList' : 'embedded', node: field.fields ?? [] };
74
74
  if (field.kind === 'collection') return { name: field.name, kind: 'collection', target: field.target, connectionField: field.connectionField };
75
75
  if (field.kind === 'reference' && !field.inferred) return { name: field.name, kind: 'reference', target: field.target };