@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 +21 -0
- package/package.json +12 -2
- package/src/after-commit.js +83 -0
- package/src/introspection-config.js +17 -0
- package/src/introspection-state.js +10 -0
- package/src/introspection.js +5 -3
- package/src/runtime.js +50 -15
- package/src/schema-registrations.js +18 -0
- package/src/transaction-callbacks.js +34 -0
- package/types/index.d.ts +35 -1
- package/types/introspection.d.ts +4 -0
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.
|
|
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
|
+
};
|
package/src/introspection.js
CHANGED
|
@@ -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) =>
|
|
1780
|
-
|
|
1781
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
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
|
-
|
|
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
|
|
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;
|