@simtlix/simfinity-core 3.14.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
@@ -38,6 +38,8 @@ Generated and custom mutations run through `adapter.withTransaction`. Retried at
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
+ 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
+
41
43
  ## Shared metadata and helpers
42
44
 
43
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.
@@ -57,3 +59,22 @@ The same helpers are available from the `@simtlix/simfinity-core/auth`, `/auth/e
57
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.
58
60
 
59
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.14.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,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
package/src/runtime.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  configureQueryLimits, getQueryMaxPageSize, getQueryMaxReferenceFrames, paginationStages,
3
3
  } from './query-limits.js';
4
4
  import { configureMutationLimits, getMaxNestedOperations } from './mutation-limits.js';
5
+ import { createAfterCommit } from './after-commit.js';
5
6
  import {
6
7
  GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
7
8
  GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
@@ -10,6 +11,7 @@ import {
10
11
  } from 'graphql';
11
12
 
12
13
  import SimfinityError from './errors/simfinity.error.js';
14
+ import { captureSchemaRegistrations } from './schema-registrations.js';
13
15
  import InternalServerError from './errors/internal-server.error.js';
14
16
  import { ForbiddenError } from './auth/errors.js';
15
17
  import QLOperator from './const/QLOperator.js';
@@ -544,6 +546,7 @@ const attemptInputs = (input, type) => {
544
546
  };
545
547
 
546
548
  export const createRuntime = (adapter) => {
549
+ const afterCommit = createAfterCommit(adapter);
547
550
  if (!adapter) {
548
551
  throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
549
552
  }
@@ -1507,6 +1510,7 @@ const assertRecordVersion = async (gqltype, id, record, expectedVersion, advance
1507
1510
  // deletes it only at that version, and a record it deleted at another one, which an adapter wrapper
1508
1511
  // that drops the options would delete, fails with DATABASE_ERROR and rolls back with the transaction.
1509
1512
  const onDelete = async (Model, controller, id, session, context, current, precondition = null) => {
1513
+ afterCommit.validate(controller);
1510
1514
  const hasHook = !!(controller && controller.onDelete);
1511
1515
  let currentObject = current;
1512
1516
  if (currentObject === undefined && (hasHook || !readByIdIsInert(Model))) {
@@ -1625,6 +1629,7 @@ const setsVersion = (update) => [update, ...Object.entries(update)
1625
1629
  // cannot set the version, and a result that did not advance it fails with DATABASE_ERROR.
1626
1630
  const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context,
1627
1631
  recordChecked = false, { expectedVersion, observedVersion, record } = {}) => {
1632
+ afterCommit.validate(controller);
1628
1633
  const versioned = isProtected(gqltype);
1629
1634
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
1630
1635
  const objectId = args.id;
@@ -1712,6 +1717,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1712
1717
  // A protected type's version is captured before the action can change `args`, and checked on the
1713
1718
  // plain pre-read record before the from-state check, which reads `state` from that record (#98).
1714
1719
  const onStateChanged = async (Model, gqltype, controller, args, session, actionField, context) => {
1720
+ afterCommit.validate(controller);
1715
1721
  const versioned = isProtected(gqltype);
1716
1722
  const expectedVersion = versioned ? assertVersionInput(args.version) : undefined;
1717
1723
  const storedModel = versioned
@@ -1738,6 +1744,7 @@ const onStateChanged = async (Model, gqltype, controller, args, session, actionF
1738
1744
  };
1739
1745
 
1740
1746
  const onSaveObject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
1747
+ afterCommit.validate(controller);
1741
1748
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'CREATE', session);
1742
1749
  if (typesDict.types[gqltype.name].stateMachine) {
1743
1750
  materializedModel.modelArgs.state = stateValue(
@@ -1776,9 +1783,13 @@ const saveObject = async (typeName, args, session, context) => {
1776
1783
  const type = typesDict.types[typeName];
1777
1784
  assertCollectionWrites(type.gqltype, args);
1778
1785
  const snapshot = cloneInput(args, type.inputType);
1779
- return adapter.withTransaction(session, (transaction) => onSaveObject(
1780
- type.model, type.gqltype, type.controller, cloneInput(snapshot, type.inputType), transaction, null, context,
1781
- ), type.model);
1786
+ return adapter.withTransaction(session, async (transaction) => {
1787
+ const attemptArgs = cloneInput(snapshot, type.inputType);
1788
+ const record = await onSaveObject(type.model, type.gqltype, type.controller, attemptArgs, transaction, null, context);
1789
+ afterCommit.queue({ typeName, controller: type.controller, operation: operations.SAVE,
1790
+ record, args: attemptArgs, context, session: transaction });
1791
+ return record;
1792
+ }, type.model);
1782
1793
  };
1783
1794
 
1784
1795
  // Counts the added, updated and deleted entries of referenced collections, at every nesting level that
@@ -1860,7 +1871,7 @@ const preReadTopLevel = async (Model, gqltype, { id, version }, advances, contex
1860
1871
  // other operation passes its input, which attemptInputs copies. Either way, a retry checks the version
1861
1872
  // the request sent.
1862
1873
  const executeOperation = (Model, gqltype, controller, args, operation, actionField,
1863
- session, context, inputType) => {
1874
+ session, context, inputType, actionName) => {
1864
1875
  const limit = getMaxNestedOperations();
1865
1876
  if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
1866
1877
  if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
@@ -1872,26 +1883,36 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
1872
1883
  session,
1873
1884
  async (mySession) => {
1874
1885
  const attemptArgs = nextInput();
1886
+ let record;
1875
1887
  switch (operation) {
1876
1888
  case operations.SAVE:
1877
- return onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
1889
+ record = await onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
1890
+ break;
1878
1891
  case operations.UPDATE: {
1879
1892
  const precondition = versioned
1880
1893
  ? await preReadTopLevel(Model, gqltype, attemptArgs, true, context, mySession) : undefined;
1881
- return onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context, false, precondition);
1894
+ record = await onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context, false, precondition);
1895
+ break;
1882
1896
  }
1883
1897
  case operations.DELETE: {
1884
- if (!versioned) return onDelete(Model, controller, attemptArgs, mySession, context);
1885
- const { expectedVersion, record } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
1886
- mySession);
1887
- return onDelete(Model, controller, attemptArgs.id, mySession, context, record, { gqltype, expectedVersion });
1898
+ if (!versioned) record = await onDelete(Model, controller, attemptArgs, mySession, context);
1899
+ else {
1900
+ const { expectedVersion, record: current } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
1901
+ mySession);
1902
+ record = await onDelete(Model, controller, attemptArgs.id, mySession, context, current, { gqltype, expectedVersion });
1903
+ }
1904
+ break;
1888
1905
  }
1889
1906
  case operations.STATE_CHANGED:
1890
- return onStateChanged(Model, gqltype, controller,
1907
+ record = await onStateChanged(Model, gqltype, controller,
1891
1908
  attemptArgs, mySession, actionField, context);
1909
+ break;
1892
1910
  default:
1893
1911
  return null;
1894
1912
  }
1913
+ afterCommit.queue({ typeName: gqltype.name, controller, operation, record, args: attemptArgs,
1914
+ context, session: mySession, action: actionName });
1915
+ return record;
1895
1916
  },
1896
1917
  Model,
1897
1918
  );
@@ -1956,19 +1977,22 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1956
1977
  }
1957
1978
  }
1958
1979
 
1980
+ let record;
1959
1981
  switch (operationType) {
1960
1982
  case operations.SAVE:
1961
- await onSaveObject(type.model, collectionGQLType, type.controller,
1983
+ record = await onSaveObject(type.model, collectionGQLType, type.controller,
1962
1984
  args.input, session, linkToParent, context);
1963
1985
  break;
1964
1986
  case operations.UPDATE:
1965
- await onUpdateSubject(type.model, collectionGQLType, type.controller,
1987
+ record = await onUpdateSubject(type.model, collectionGQLType, type.controller,
1966
1988
  args.input, session, linkToParent, context, true, versioned ? { expectedVersion, observedVersion, record: child } : undefined);
1967
1989
  break;
1968
1990
  case operations.DELETE:
1969
- await onDelete(type.model, type.controller, args.id, session, context, child,
1991
+ record = await onDelete(type.model, type.controller, args.id, session, context, child,
1970
1992
  versioned ? { gqltype: collectionGQLType, expectedVersion } : null);
1971
1993
  }
1994
+ afterCommit.queue({ typeName: collectionGQLType.name, controller: type.controller,
1995
+ operation: operationType, record, args: args.input, context, session });
1972
1996
  }
1973
1997
  };
1974
1998
 
@@ -3158,7 +3182,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
3158
3182
  await executeMiddleware(params);
3159
3183
  return executeOperation(type.model, type.gqltype, type.controller,
3160
3184
  args.input, operations.STATE_CHANGED, actionField,
3161
- null, context, type.inputType);
3185
+ null, context, type.inputType, actionName);
3162
3186
  },
3163
3187
  };
3164
3188
  }
@@ -3893,6 +3917,7 @@ const readSchemaOptions = (options) => {
3893
3917
  };
3894
3918
 
3895
3919
  const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations, options) => {
3920
+ Object.values(typesDict.types).forEach(({ controller }) => afterCommit.validate(controller));
3896
3921
  // Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
3897
3922
  const { includedCustomQueries } = readSchemaOptions(options);
3898
3923
  assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
@@ -3980,6 +4005,15 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3980
4005
  }
3981
4006
 
3982
4007
  const schema = constructSchema(query, mutation, reserveVersionNames);
4008
+ captureSchemaRegistrations(schema, Object.values(typesDict.types).map((entry) => ({
4009
+ gqltype: entry.gqltype, endpoint: entry.endpoint,
4010
+ // Only associate roots generated for this schema. Excluded endpoint names can be custom queries.
4011
+ simpleEntityEndpointName: shouldNotBeIncludedInSchema(includedQueryTypes, entry.gqltype)
4012
+ ? null : entry.simpleEntityEndpointName,
4013
+ inputType: entry.inputType, updateInputType: typesDictForUpdate.types[entry.gqltype.name]?.inputType,
4014
+ stateMachine: entry.stateMachine,
4015
+ })), (type, field) => field.extensions?.queryable !== false
4016
+ && (field.extensions?.queryable === true || !hasApplicationResolver(type, field.name)));
3983
4017
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
3984
4018
  // custom operation result, so another runtime cannot later generate resolvers this schema executes.
3985
4019
  schemaTypes.forEach((type) => {
@@ -4335,6 +4369,7 @@ function formatArgs(argsArray) {
4335
4369
  return {
4336
4370
  configureQueryLimits,
4337
4371
  configureMutationLimits,
4372
+ configureAfterCommit: afterCommit.configure,
4338
4373
  connect,
4339
4374
  addNoEndpointType,
4340
4375
  createSchema,
@@ -0,0 +1,18 @@
1
+ // Module-local ownership shared by consumers of this exact core installation. The snapshot is
2
+ // registration data only; descriptor formats and HTTP belong to opt-in consumer packages.
3
+ const registrationsBySchema = new WeakMap();
4
+
5
+ export const captureSchemaRegistrations = (schema, registrations, queryable) => {
6
+ const snapshot = registrations.map((entry) => Object.freeze({
7
+ ...entry,
8
+ stateMachine: entry.stateMachine ? Object.freeze({
9
+ initialState: entry.stateMachine.initialState
10
+ ? Object.freeze({ ...entry.stateMachine.initialState }) : null,
11
+ }) : null,
12
+ queryableFields: Object.freeze(Object.fromEntries(Object.values(entry.gqltype.getFields())
13
+ .map((field) => [field.name, queryable(entry.gqltype, field)]))),
14
+ }));
15
+ registrationsBySchema.set(schema, Object.freeze(snapshot));
16
+ };
17
+
18
+ export const schemaRegistrations = (schema) => registrationsBySchema.get(schema);
@@ -0,0 +1,34 @@
1
+ // The transaction owner creates a new queue for every attempt, even when the driver reuses the
2
+ // session. No session is adopted merely because it reports an active transaction.
3
+ export const createTransactionCallbacks = () => {
4
+ const active = new WeakMap();
5
+ const enqueue = (session, run) => {
6
+ const jobs = active.get(session);
7
+ if (!jobs) return false;
8
+ jobs.push(run);
9
+ return true;
10
+ };
11
+ const begin = (session) => {
12
+ const jobs = [];
13
+ let state = 'active';
14
+ const detach = () => { if (active.get(session) === jobs) active.delete(session); };
15
+ active.set(session, jobs);
16
+ return {
17
+ seal() { if (state === 'active') { state = 'sealed'; detach(); } },
18
+ confirm() { if (state === 'sealed') state = 'confirmed'; },
19
+ discard() { if (state !== 'confirmed' && state !== 'drained') { state = 'discarded'; detach(); jobs.length = 0; } },
20
+ async drain() {
21
+ if (state !== 'confirmed') return;
22
+ state = 'drained';
23
+ for (const run of jobs.splice(0)) {
24
+ try { await run(); } catch {
25
+ // Runtime jobs report their own failures. A faulty adapter consumer cannot change an
26
+ // already committed result, stop later jobs, or enter the driver's retry classifier.
27
+ try { console.error('After-commit callback failed'); } catch { /* Logging is best effort. */ }
28
+ }
29
+ }
30
+ },
31
+ };
32
+ };
33
+ return { begin, enqueue };
34
+ };
package/types/index.d.ts CHANGED
@@ -333,7 +333,27 @@ export class InternalServerError extends SimfinityError {
333
333
  */
334
334
  export function buildErrorFormatter(callback?: (error: SimfinityError) => Error | void): (error: unknown) => GraphQLError;
335
335
 
336
- export interface EntityController<Session = any> {
336
+ export interface AfterCommitErrorDetails {
337
+ committed: true;
338
+ typeName: string;
339
+ operation: 'save' | 'update' | 'delete' | 'state_changed';
340
+ callbackName: 'onSaveCommitted' | 'onUpdateCommitted' | 'onDeleteCommitted' | 'onTransitionCommitted';
341
+ context: any;
342
+ }
343
+ export interface AfterCommitOptions {
344
+ /** Operational reporting only. Rejections are caught and never fail the committed write. */
345
+ onError?: (error: unknown, details: AfterCommitErrorDetails) => void | Promise<void>;
346
+ }
347
+ export interface CommittedControllerCallbacks {
348
+ /** After the owning transaction commits; a detached result and the successful attempt's input. */
349
+ onSaveCommitted?(record: any, args: any, context: any): void | Promise<void>;
350
+ onUpdateCommitted?(record: any, context: any): void | Promise<void>;
351
+ /** Called only when a record was actually deleted. */
352
+ onDeleteCommitted?(record: any, context: any): void | Promise<void>;
353
+ /** Exclusive notification for a state action; action is its registered name. */
354
+ onTransitionCommitted?(record: any, action: string, context: any): void | Promise<void>;
355
+ }
356
+ export interface EntityController<Session = any> extends CommittedControllerCallbacks {
337
357
  onSaving?(record: any, args: any, session: Session | undefined, context: any): void | Promise<void>;
338
358
  onSaved?(record: any, args: any, session: Session | undefined, context: any): void | Promise<void>;
339
359
  onUpdating?(id: any, update: any, session: Session | undefined, context: any): void | Promise<void>;
@@ -485,6 +505,8 @@ export interface ViewOptions {
485
505
  }
486
506
 
487
507
  export interface Runtime<Model = any, Session = any> {
508
+ /** Per runtime. No arguments resets to safe default logging; invalid options leave it unchanged. */
509
+ configureAfterCommit(options?: AfterCommitOptions): void;
488
510
  /** The same process-wide setting as the root `configureQueryLimits`. */
489
511
  configureQueryLimits(options?: QueryLimitsOptions): void;
490
512
  /**
@@ -638,6 +660,15 @@ export interface DatabaseAdapter<Model = any, Session = any> {
638
660
  castId(value: any): any;
639
661
  stateValue?(state: { name: string; value: any }): any;
640
662
  withTransaction<T>(session: Session | null | undefined, callback: (session: Session) => Promise<T> | T, model?: Model): Promise<T>;
663
+ /**
664
+ * Optional capability for controller callbacks. Accept only into an active attempt owned by this
665
+ * adapter; false means the supplied session has no owned attempt. A new queue is required per
666
+ * retry, including retries on the same native session. Seal before commit, discard on abort or
667
+ * unresolved uncertainty, and drain once, serially, after confirmed commit and cleanup attempt.
668
+ * Callback failures must never enter transaction retry/rollback paths or fail a committed write.
669
+ * Borrowed operations join the owning attempt; this method does not observe external commits.
670
+ */
671
+ enqueueAfterCommit?(session: Session, run: () => void | Promise<void>): boolean;
641
672
  newRecord(model: Model, data: any, session?: Session): any;
642
673
  saveRecord(model: Model, record: any, session?: Session): any;
643
674
  toObject(record: any): any;
@@ -1093,3 +1124,6 @@ export const plugins: {
1093
1124
  };
1094
1125
  };
1095
1126
  };
1127
+
1128
+ /** Early opt-out. Import its function from /introspection before loading the runtime. */
1129
+ export interface IntrospectionOptions { fieldExtensions: false; }
@@ -0,0 +1,4 @@
1
+ import type { IntrospectionOptions } from './index.js';
2
+ export type { IntrospectionOptions } from './index.js';
3
+ /** Must run before importing the runtime/facades. Never removes installed metadata. */
4
+ export function configureIntrospection(options: IntrospectionOptions): void;