@simtlix/simfinity-js 3.5.2 → 3.5.5
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 +2 -0
- package/package.json +3 -3
- package/src/index.js +1 -0
- package/src/mongo/adapter.js +67 -27
- package/src/mongo/ids.js +60 -0
- package/src/mongo/integrity.js +7 -3
- package/src/mongo/models.js +2 -2
- package/src/mongo/queries.js +36 -37
- package/src/mongo/transactions.js +24 -5
- package/types/index.d.ts +53 -5
package/README.md
CHANGED
|
@@ -24,6 +24,8 @@ Generated single-reference reads can be batched within a request. Models with an
|
|
|
24
24
|
|
|
25
25
|
Generated models store enum internal values with their own type (numbers, booleans or strings; other combinations as given) and support embedded fields named `type`. Range filters, sorts and aggregates on numeric and boolean enums follow that type; PostgreSQL uses their text form. Earlier versions stored numeric and boolean enum values as strings; convert existing documents once as described in the [schema guide](https://simtlix.github.io/simfinity.js/guide/schema.html#what-gets-generated).
|
|
26
26
|
|
|
27
|
+
The facade registers generated models on your application's default Mongoose instance and does not change global Mongoose options such as `strictQuery`. `createMongoAdapter().castId` accepts only an ObjectId or its 24-character hexadecimal string and throws `NOT_VALID_ID` (400) otherwise; create new identifiers with `new mongoose.Types.ObjectId()`. Owned transactions retry transient failures after a short random delay, and a write conflict that outlasts the retries fails with `TRANSACTION_RETRY_EXCEEDED` (409).
|
|
28
|
+
|
|
27
29
|
Mongoose 8.24.2 or later within version 8 is required to exclude the update-casting prototype-pollution vulnerability. Existing applications should update Mongoose and their dependency lockfile, then run `npm audit`; upgrading Simfinity does not refresh every transitive dependency already locked by the application.
|
|
28
30
|
|
|
29
31
|
## Optional transactional reference integrity
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.5",
|
|
4
4
|
"description": "MongoDB/Mongoose adapter and compatibility facade for Simfinity GraphQL APIs",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "./types/index.d.ts",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"owner": "simtlix"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@simtlix/simfinity-core": "3.5.
|
|
33
|
-
"@simtlix/simfinity-mcp": "3.5.
|
|
32
|
+
"@simtlix/simfinity-core": "3.5.5",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.5.5"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
package/src/index.js
CHANGED
|
@@ -12,6 +12,7 @@ const mongoAdapter = createMongoAdapter();
|
|
|
12
12
|
const runtime = createRuntime(mongoAdapter);
|
|
13
13
|
|
|
14
14
|
export const configureQueryLimits = runtime.configureQueryLimits;
|
|
15
|
+
export const configureMutationLimits = runtime.configureMutationLimits;
|
|
15
16
|
export const connect = runtime.connect;
|
|
16
17
|
export const addNoEndpointType = runtime.addNoEndpointType;
|
|
17
18
|
export const createSchema = runtime.createSchema;
|
package/src/mongo/adapter.js
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
import mongoose from 'mongoose';
|
|
2
|
-
import {
|
|
2
|
+
import { getListShape, normalizeConnectionField } from '@simtlix/simfinity-core/internal/relation-storage';
|
|
3
3
|
|
|
4
4
|
import { createMongoModel } from './models.js';
|
|
5
5
|
import { createMongoQueries } from './queries.js';
|
|
6
6
|
import { withMongoTransaction } from './transactions.js';
|
|
7
7
|
import { createMongoIntegrity } from './integrity.js';
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
castModelId, castObjectId, castPathValue, invalidId, isQueryKeyCastFailure, mapIdCastError,
|
|
10
|
+
} from './ids.js';
|
|
10
11
|
|
|
11
12
|
const withSession = (query, session) => (session ? query.session(session) : query);
|
|
12
13
|
|
|
14
|
+
// A write that fails only on malformed ObjectId values reports NOT_VALID_ID, not a driver error.
|
|
15
|
+
const mapIdCastErrors = async (write) => {
|
|
16
|
+
try {
|
|
17
|
+
return await write();
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw mapIdCastError(error);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// By-ID queries take the raw ID, so the model's setters and key cast run once, in Mongoose's own
|
|
24
|
+
// query lifecycle. Keep the chainable Query and map a failed key cast only when it runs; with
|
|
25
|
+
// `mapError`, also map other failures, such as malformed ObjectId values in an update.
|
|
26
|
+
const mapQueryIdCastErrors = (query, mapError = (error) => error) => {
|
|
27
|
+
const { exec } = query;
|
|
28
|
+
if (typeof exec !== 'function') return query;
|
|
29
|
+
query.exec = async function execMappingIdCasts(...args) {
|
|
30
|
+
try {
|
|
31
|
+
return await exec.apply(this, args);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw isQueryKeyCastFailure(this, error) ? invalidId() : mapError(error);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return query;
|
|
37
|
+
};
|
|
38
|
+
|
|
13
39
|
// Even a shared hook can branch on `this.op` or the filter shape. Preserve findOne semantics
|
|
14
40
|
// whenever either query operation has hooks instead of inferring equivalence from function identity.
|
|
15
41
|
const hasNoFindHooks = (hooks) => {
|
|
@@ -45,20 +71,15 @@ export const createMongoAdapter = (options) => {
|
|
|
45
71
|
for (const registration of registrations) {
|
|
46
72
|
for (const field of Object.values(registration.gqltype.getFields())) {
|
|
47
73
|
const relation = field.extensions?.relation;
|
|
48
|
-
const
|
|
49
|
-
if (!relation?.connectionField || relation.embedded || !
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
});
|
|
58
|
-
if (!connectionExists) addPrivateConnectionField(
|
|
59
|
-
childType.name,
|
|
60
|
-
relation.connectionField,
|
|
61
|
-
);
|
|
74
|
+
const listShape = getListShape(field.type);
|
|
75
|
+
if (!relation?.connectionField || relation.embedded || !listShape) continue;
|
|
76
|
+
// Same rule as core: only a direct or aliased singular reference on the child stores the
|
|
77
|
+
// link. Otherwise, including a child collection that reuses the name (chained or
|
|
78
|
+
// self-referencing collections), the child gets a private ObjectId field.
|
|
79
|
+
const connection = normalizeConnectionField(listShape.itemType, relation.connectionField);
|
|
80
|
+
if (!connection.graphqlFieldName) {
|
|
81
|
+
addPrivateConnectionField(listShape.itemType.name, connection.storageFieldName);
|
|
82
|
+
}
|
|
62
83
|
}
|
|
63
84
|
}
|
|
64
85
|
},
|
|
@@ -71,9 +92,8 @@ export const createMongoAdapter = (options) => {
|
|
|
71
92
|
if (onModelCreated) onModelCreated(model);
|
|
72
93
|
}, integrity.enabled ? { ...modelOptions, createCollection: false } : modelOptions);
|
|
73
94
|
},
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
},
|
|
95
|
+
// Relation inputs and batch keys: only an ObjectId or its hex form, never a newly minted one.
|
|
96
|
+
castId: castObjectId,
|
|
77
97
|
initialize: integrity.initialize,
|
|
78
98
|
withTransaction: integrity.enabled ? integrity.withTransaction : withMongoTransaction,
|
|
79
99
|
newRecord(Model, data, session) {
|
|
@@ -83,17 +103,27 @@ export const createMongoAdapter = (options) => {
|
|
|
83
103
|
},
|
|
84
104
|
saveRecord(Model, record, session) {
|
|
85
105
|
if (integrity.enabled) return integrity.saveRecord(Model, record, session === undefined ? record.$session() : session);
|
|
86
|
-
return record.save();
|
|
106
|
+
return mapIdCastErrors(() => record.save());
|
|
87
107
|
},
|
|
88
108
|
toObject(record) {
|
|
89
109
|
return typeof record?.toObject === 'function' ? record.toObject() : record;
|
|
90
110
|
},
|
|
111
|
+
// A hydrated document renders a singular embedded path as an object even when the stored value
|
|
112
|
+
// is an explicit null. Only that stored null reads as null; an absent value keeps Mongoose's
|
|
113
|
+
// materialized object, as legacy documents rely on.
|
|
114
|
+
readEmbeddedValue(value) {
|
|
115
|
+
// The nested accessor's toJSON is called without a receiver on purpose. It reads the schema's
|
|
116
|
+
// toJSON virtuals option from `this` (guarded by `this &&`), and with that option set a stored
|
|
117
|
+
// null renders as `{}`. Without a receiver it reads the raw path value of its own document.
|
|
118
|
+
return value?.$__isNested === true && typeof value.toJSON === 'function' && value.toJSON.call(null) === null
|
|
119
|
+
? null : value;
|
|
120
|
+
},
|
|
91
121
|
getById(Model, id, session, { projection, plain, requiredId } = {}) {
|
|
92
122
|
integrity.assertReady();
|
|
93
123
|
let query = withSession(requiredId == null ? Model.findById(id, projection)
|
|
94
124
|
: Model.findOne({ $and: [{ _id: requiredId }, { _id: id }] }, projection), session);
|
|
95
125
|
if (plain) query = query.lean();
|
|
96
|
-
return query;
|
|
126
|
+
return mapQueryIdCastErrors(query);
|
|
97
127
|
},
|
|
98
128
|
getByIds(Model, ids) {
|
|
99
129
|
integrity.assertReady();
|
|
@@ -108,19 +138,22 @@ export const createMongoAdapter = (options) => {
|
|
|
108
138
|
},
|
|
109
139
|
update(Model, id, update, session) {
|
|
110
140
|
if (integrity.enabled) return integrity.updateRecord(Model, id, update, session);
|
|
111
|
-
return withSession(
|
|
141
|
+
return mapQueryIdCastErrors(withSession(
|
|
142
|
+
Model.findByIdAndUpdate(id, update, { new: true }), session,
|
|
143
|
+
), mapIdCastError);
|
|
112
144
|
},
|
|
113
145
|
delete(Model, id, session) {
|
|
114
146
|
if (integrity.enabled) {
|
|
115
147
|
integrity.assertWrite(Model, session);
|
|
116
148
|
return integrity.deleteRecord(Model, id, session);
|
|
117
149
|
}
|
|
118
|
-
return withSession(Model.findByIdAndDelete(id), session);
|
|
150
|
+
return mapQueryIdCastErrors(withSession(Model.findByIdAndDelete(id), session));
|
|
119
151
|
},
|
|
120
152
|
async find(Model, gqltype, args, session, { requiredId } = {}) {
|
|
121
153
|
integrity.assertReady();
|
|
122
154
|
const pipeline = await queries.buildQuery(args, gqltype);
|
|
123
|
-
|
|
155
|
+
// Mongoose does not cast aggregation stages, so cast the key type here, without setters.
|
|
156
|
+
if (requiredId != null) pipeline.unshift({ $match: { _id: castModelId(Model, requiredId) } });
|
|
124
157
|
if (pipeline.length === 0) return withSession(Model.find({}), session);
|
|
125
158
|
return withSession(Model.aggregate(pipeline), session);
|
|
126
159
|
},
|
|
@@ -138,8 +171,15 @@ export const createMongoAdapter = (options) => {
|
|
|
138
171
|
async findChildren(Model, gqltype, connectionField, parentId, args, session) {
|
|
139
172
|
integrity.assertReady();
|
|
140
173
|
const pipeline = await queries.buildQuery(args, gqltype);
|
|
141
|
-
|
|
142
|
-
|
|
174
|
+
let parentKey;
|
|
175
|
+
try {
|
|
176
|
+
parentKey = castPathValue(Model.schema.path(connectionField), parentId);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// A stored parent key that the connection path cannot cast has no children.
|
|
179
|
+
if (error?.extensions?.code === 'NOT_VALID_ID') return [];
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
pipeline.unshift({ $match: { [connectionField]: parentKey } });
|
|
143
183
|
return withSession(Model.aggregate(pipeline), session);
|
|
144
184
|
},
|
|
145
185
|
};
|
package/src/mongo/ids.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import { SimfinityError } from '@simtlix/simfinity-core';
|
|
3
|
+
|
|
4
|
+
// Identifier casts shared by the adapter, the query builder and the integrity layer. A malformed
|
|
5
|
+
// identifier is the client's error, so it becomes NOT_VALID_ID (400) instead of a driver error, and
|
|
6
|
+
// the message never echoes the value.
|
|
7
|
+
|
|
8
|
+
export const invalidId = () => new SimfinityError('Invalid identifier', 'NOT_VALID_ID', 400);
|
|
9
|
+
|
|
10
|
+
// Names also match a CastError or BSONError from another mongoose or bson copy used by a supplied model.
|
|
11
|
+
const isCastFailure = (error) => error?.name === 'CastError' || error?.name === 'BSONError';
|
|
12
|
+
|
|
13
|
+
// Accepts only an ObjectId or its 24-character hex form, so a missing or numeric value never mints a
|
|
14
|
+
// new identifier.
|
|
15
|
+
export const castObjectId = (value) => {
|
|
16
|
+
try {
|
|
17
|
+
if (mongoose.isObjectIdOrHexString(value)) return new mongoose.Types.ObjectId(value);
|
|
18
|
+
} catch {
|
|
19
|
+
// Fall through: an ObjectId-like value that does not convert is malformed.
|
|
20
|
+
}
|
|
21
|
+
throw invalidId();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Casts with the model's own path type, as Mongoose would at query time, so supplied models with a
|
|
25
|
+
// String or Number key keep working. Mongoose matches nothing for null or undefined.
|
|
26
|
+
export const castPathValue = (schemaType, value) => {
|
|
27
|
+
if (value == null || !schemaType) return value;
|
|
28
|
+
try {
|
|
29
|
+
return schemaType.cast(value);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (isCastFailure(error)) throw invalidId();
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const castModelId = (Model, value) => castPathValue(Model.schema.path('_id'), value);
|
|
37
|
+
|
|
38
|
+
// A Mongoose query casts its filter, with the model's setters, when it runs, and keeps a failure as
|
|
39
|
+
// its error flag. A failed cast of the record key there is a malformed identifier, whatever the key
|
|
40
|
+
// type; a key cast failure in an update document, or an error that error middleware substitutes, is not.
|
|
41
|
+
export const isQueryKeyCastFailure = (query, error) => error?.name === 'CastError' && error.path === '_id'
|
|
42
|
+
&& typeof query?.error === 'function' && query.error() === error;
|
|
43
|
+
|
|
44
|
+
// A write that fails only because ObjectId values do not cast: an ObjectId CastError, an embedded or
|
|
45
|
+
// array cast wrapping one, or a ValidationError made only of such casts. Any other failure keeps its
|
|
46
|
+
// own error.
|
|
47
|
+
const isObjectIdCastFailure = (error) => {
|
|
48
|
+
if (error?.name === 'CastError') {
|
|
49
|
+
if (error.kind === 'ObjectId') return true;
|
|
50
|
+
const wraps = error.kind === 'embedded' || error.kind === 'Array' || /^\[.*\]$/.test(error.kind ?? '');
|
|
51
|
+
return wraps && isObjectIdCastFailure(error.reason);
|
|
52
|
+
}
|
|
53
|
+
if (error?.name === 'ValidationError') {
|
|
54
|
+
const nested = Object.values(error.errors ?? {});
|
|
55
|
+
return nested.length > 0 && nested.every(isObjectIdCastFailure);
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const mapIdCastError = (error) => (isObjectIdCastFailure(error) ? invalidId() : error);
|
package/src/mongo/integrity.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import mongoose from 'mongoose';
|
|
2
2
|
import { describeModels, SimfinityError } from '@simtlix/simfinity-core';
|
|
3
3
|
import { withMongoTransaction } from './transactions.js';
|
|
4
|
+
import { castModelId, mapIdCastError } from './ids.js';
|
|
4
5
|
|
|
5
6
|
const LOCK_FIELD = '_simfinityReferenceLock';
|
|
6
7
|
// Startup audit bounds: distinct target ids checked per query and ids remembered per target.
|
|
@@ -108,7 +109,8 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
108
109
|
// A caller must not catch that error and commit a borrowed transaction.
|
|
109
110
|
try { await session.abortTransaction(); } catch { /* Preserve the write error and retry labels. */ }
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
+
// A malformed identifier is reported as NOT_VALID_ID; the transaction is still aborted.
|
|
113
|
+
throw mapIdCastError(error);
|
|
112
114
|
}
|
|
113
115
|
};
|
|
114
116
|
|
|
@@ -254,10 +256,12 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
254
256
|
return saved;
|
|
255
257
|
});
|
|
256
258
|
},
|
|
259
|
+
// Protected writes cast the key before querying, without setters, as 3.5.3 did: the lock, the
|
|
260
|
+
// reference checks and the identity checks must all use the key requested before middleware runs.
|
|
257
261
|
async updateRecord(Model, id, update, session) {
|
|
258
262
|
assertWrite(Model, session);
|
|
259
263
|
return abortOnWriteError(session, async () => {
|
|
260
|
-
const objectId = Model
|
|
264
|
+
const objectId = castModelId(Model, id);
|
|
261
265
|
const updated = await Model.findByIdAndUpdate(objectId, update, { new: true }).session(session);
|
|
262
266
|
if (!updated) {
|
|
263
267
|
// A middleware can hide a completed write with a null result. Abort
|
|
@@ -272,7 +276,7 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
272
276
|
},
|
|
273
277
|
async deleteRecord(Model, id, session) {
|
|
274
278
|
return abortOnWriteError(session, async () => {
|
|
275
|
-
const objectId = Model
|
|
279
|
+
const objectId = castModelId(Model, id);
|
|
276
280
|
if (!await lock(Model, objectId, session)) return null;
|
|
277
281
|
for (const reference of incoming.get(Model) || []) {
|
|
278
282
|
const query = reference.Model === Model
|
package/src/mongo/models.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
GraphQLString,
|
|
12
12
|
} from 'graphql';
|
|
13
13
|
import mongoose from 'mongoose';
|
|
14
|
+
import { getFieldStorageName } from '@simtlix/simfinity-core/internal/relation-storage';
|
|
14
15
|
|
|
15
16
|
const isNonNullOfType = (fieldEntryType, graphQLType) => (
|
|
16
17
|
fieldEntryType instanceof GraphQLNonNull && fieldEntryType.ofType instanceof graphQLType
|
|
@@ -90,8 +91,7 @@ const generateSchemaDefinition = (gqlType, nested = false) => {
|
|
|
90
91
|
} else if (type instanceof GraphQLObjectType || isNonNullOfType(type, GraphQLObjectType)) {
|
|
91
92
|
if (fieldEntry.extensions && fieldEntry.extensions.relation) {
|
|
92
93
|
if (!fieldEntry.extensions.relation.embedded) {
|
|
93
|
-
|
|
94
|
-
schemaArg[key] = mongoose.Schema.Types.ObjectId;
|
|
94
|
+
schemaArg[getFieldStorageName(fieldEntryName, fieldEntry)] = mongoose.Schema.Types.ObjectId;
|
|
95
95
|
} else {
|
|
96
96
|
const entryType = unwrapNonNull(type);
|
|
97
97
|
if (entryType === gqlType) {
|
package/src/mongo/queries.js
CHANGED
|
@@ -9,15 +9,17 @@ import {
|
|
|
9
9
|
import mongoose from 'mongoose';
|
|
10
10
|
|
|
11
11
|
import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity-core';
|
|
12
|
+
import {
|
|
13
|
+
getFieldStorageName, getListShape, hasStoredIdentity, normalizeConnectionField,
|
|
14
|
+
} from '@simtlix/simfinity-core/internal/relation-storage';
|
|
12
15
|
|
|
16
|
+
import { castObjectId } from './ids.js';
|
|
13
17
|
import { resolveStorageScalar } from './models.js';
|
|
14
18
|
|
|
15
19
|
const isNonNullOfType = (fieldEntryType, graphQLType) => (
|
|
16
20
|
fieldEntryType instanceof GraphQLNonNull && fieldEntryType.ofType instanceof graphQLType
|
|
17
21
|
);
|
|
18
22
|
|
|
19
|
-
const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
|
|
20
|
-
|
|
21
23
|
const unwrapListAndNonNull = (type) => {
|
|
22
24
|
let unwrapped = type;
|
|
23
25
|
while (unwrapped instanceof GraphQLList || unwrapped instanceof GraphQLNonNull) {
|
|
@@ -26,28 +28,6 @@ const unwrapListAndNonNull = (type) => {
|
|
|
26
28
|
return unwrapped;
|
|
27
29
|
};
|
|
28
30
|
|
|
29
|
-
const isListType = (type) => unwrapNonNull(type) instanceof GraphQLList;
|
|
30
|
-
|
|
31
|
-
const getFieldStorageName = (fieldName, field) => {
|
|
32
|
-
const relation = field.extensions?.relation;
|
|
33
|
-
return relation && !relation.embedded && !isListType(field.type)
|
|
34
|
-
? relation.connectionField || fieldName
|
|
35
|
-
: fieldName;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const getConnectionStorageName = (childType, declaredFieldName) => {
|
|
39
|
-
const childFields = childType.getFields();
|
|
40
|
-
if (childFields[declaredFieldName]) {
|
|
41
|
-
return getFieldStorageName(declaredFieldName, childFields[declaredFieldName]);
|
|
42
|
-
}
|
|
43
|
-
const aliasedField = Object.entries(childFields).find(([fieldName, field]) => (
|
|
44
|
-
getFieldStorageName(fieldName, field) === declaredFieldName
|
|
45
|
-
));
|
|
46
|
-
return aliasedField
|
|
47
|
-
? getFieldStorageName(aliasedField[0], aliasedField[1])
|
|
48
|
-
: declaredFieldName;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
31
|
const getEffectiveTypeName = (type) => (
|
|
52
32
|
type instanceof GraphQLScalarType ? resolveStorageScalar(type).name : type.name
|
|
53
33
|
);
|
|
@@ -153,6 +133,19 @@ const queryNamedType = (type) => {
|
|
|
153
133
|
return result;
|
|
154
134
|
};
|
|
155
135
|
|
|
136
|
+
// `id` is the record key `_id` at an entity boundary: the root or a joined document. Inside an
|
|
137
|
+
// embedded object it follows what reads return. Entity types read the stored subdocument `_id`
|
|
138
|
+
// through the generated id resolver; that resolver lives on the type object, so any runtime that
|
|
139
|
+
// marked the type an entity decides it, not only this one. Every other embedded object stores and
|
|
140
|
+
// returns its declared `id` member. A supplied subdocument schema without an `id` path keeps `_id`,
|
|
141
|
+
// which only hydrated reads expose, through Mongoose's `id` virtual; list reads return null for it.
|
|
142
|
+
const identifierLeaf = (model, schemaPath, gqltype) => {
|
|
143
|
+
if (!schemaPath) return '_id';
|
|
144
|
+
const schema = model?.schema;
|
|
145
|
+
if (!schema?.path(`${schemaPath}._id`)) return 'id';
|
|
146
|
+
return hasStoredIdentity(gqltype) || !schema.path(`${schemaPath}.id`) ? '_id' : 'id';
|
|
147
|
+
};
|
|
148
|
+
|
|
156
149
|
// Resolve GraphQL paths and storage paths together; supplied models own ID casting.
|
|
157
150
|
const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
158
151
|
assertValidFilterPath(path);
|
|
@@ -176,9 +169,11 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
|
176
169
|
} else {
|
|
177
170
|
const relatedModel = getModel(fieldType);
|
|
178
171
|
if (!relatedModel) throw filterError(`Related model is not available for ${path}`, 'INVALID_FILTER_PATH');
|
|
179
|
-
const isList =
|
|
180
|
-
|
|
181
|
-
const connField = isList
|
|
172
|
+
const isList = !!getListShape(field.type);
|
|
173
|
+
// A collection without connectionField keeps reading through its field name, as before.
|
|
174
|
+
const connField = isList
|
|
175
|
+
? normalizeConnectionField(fieldType, relation.connectionField || part).storageFieldName
|
|
176
|
+
: getFieldStorageName(part, field);
|
|
182
177
|
const localLeaf = isList ? '_id' : connField;
|
|
183
178
|
const alias = allocateLookupAlias(aggregationsIncluded, parts.slice(0, index + 1).join('.'));
|
|
184
179
|
aggregateClauses[alias] = buildRelationLookup({
|
|
@@ -194,7 +189,7 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
|
194
189
|
currentType = fieldType;
|
|
195
190
|
} else {
|
|
196
191
|
if (index !== parts.length - 1) throw filterError(`Cannot traverse scalar field in ${path}`, 'INVALID_FILTER_PATH');
|
|
197
|
-
const leaf = part === 'id' ?
|
|
192
|
+
const leaf = part === 'id' ? identifierLeaf(currentModel, schemaPath, currentType) : part;
|
|
198
193
|
const storagePath = schemaPath ? `${schemaPath}.${leaf}` : leaf;
|
|
199
194
|
return {
|
|
200
195
|
mongoPath: mongoPath ? `${mongoPath}.${leaf}` : leaf,
|
|
@@ -228,7 +223,7 @@ const castFilterValue = (value, resolved) => {
|
|
|
228
223
|
if (Number.isNaN(date.getTime())) throw new Error('Invalid date');
|
|
229
224
|
return date;
|
|
230
225
|
}
|
|
231
|
-
if (scalar === GraphQLID && !schemaType) return
|
|
226
|
+
if (scalar === GraphQLID && !schemaType) return castObjectId(value);
|
|
232
227
|
if (scalar instanceof GraphQLEnumType) {
|
|
233
228
|
const entry = scalar.getValues().find((item) => item.name === value)
|
|
234
229
|
|| scalar.getValues().find((item) => item.value === value);
|
|
@@ -289,9 +284,9 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
|
|
|
289
284
|
|
|
290
285
|
const MAX_FILTER_GROUP_DEPTH = 5;
|
|
291
286
|
|
|
292
|
-
const validateLogicalLists = (group) => {
|
|
287
|
+
const validateLogicalLists = (group, keys = ['AND', 'OR', 'conditions']) => {
|
|
293
288
|
if (!group || typeof group !== 'object' || Array.isArray(group)) throw filterError('Expected a filter group object');
|
|
294
|
-
for (const key of
|
|
289
|
+
for (const key of keys) {
|
|
295
290
|
if (group[key] != null && !Array.isArray(group[key])) throw filterError(`${key} requires an array`);
|
|
296
291
|
}
|
|
297
292
|
};
|
|
@@ -367,17 +362,21 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
367
362
|
return { $and: parts };
|
|
368
363
|
};
|
|
369
364
|
|
|
370
|
-
const
|
|
365
|
+
const FIND_CONTROL_KEYS = new Set(['pagination', 'sort', 'AND', 'OR']);
|
|
366
|
+
// `aggregation` is the expression argument only on aggregate queries; elsewhere it names a field.
|
|
367
|
+
const AGGREGATE_CONTROL_KEYS = new Set([...FIND_CONTROL_KEYS, 'aggregation']);
|
|
371
368
|
|
|
372
|
-
const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggregationsIncluded) => {
|
|
373
|
-
|
|
369
|
+
const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggregationsIncluded, operation = 'find') => {
|
|
370
|
+
// Top-level arguments are field filters plus AND/OR groups; `conditions` is a key only inside groups.
|
|
371
|
+
validateLogicalLists(input, ['AND', 'OR']);
|
|
372
|
+
const controlKeys = operation === 'aggregate' ? AGGREGATE_CONTROL_KEYS : FIND_CONTROL_KEYS;
|
|
374
373
|
const flatMatchConditions = {};
|
|
375
374
|
const repeatedConditions = [];
|
|
376
375
|
let hasFlat = false;
|
|
377
376
|
const fields = gqltype.getFields();
|
|
378
377
|
|
|
379
378
|
for (const [key, filterField] of Object.entries(input)) {
|
|
380
|
-
if (
|
|
379
|
+
if (controlKeys.has(key)) continue;
|
|
381
380
|
const qlField = fields[key];
|
|
382
381
|
const result = await buildQueryTerms(filterField, qlField, key, gqltype, aggregationsIncluded);
|
|
383
382
|
if (!result) continue;
|
|
@@ -448,7 +447,7 @@ const buildQuery = async (input, gqltype, isCount) => {
|
|
|
448
447
|
const aggregationsIncluded = {};
|
|
449
448
|
const paging = paginationStages(input.pagination, true);
|
|
450
449
|
|
|
451
|
-
const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded);
|
|
450
|
+
const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded, 'find');
|
|
452
451
|
if (matchStage) aggregateClauses.push(matchStage);
|
|
453
452
|
|
|
454
453
|
if (isCount) {
|
|
@@ -476,7 +475,7 @@ const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
|
|
|
476
475
|
const aggregationsIncluded = {};
|
|
477
476
|
const paging = paginationStages(input.pagination, false);
|
|
478
477
|
|
|
479
|
-
const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded);
|
|
478
|
+
const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded, 'aggregate');
|
|
480
479
|
if (matchStage) aggregateClauses.push(matchStage);
|
|
481
480
|
|
|
482
481
|
const { groupId, facts } = aggregationExpression ?? {};
|
|
@@ -3,6 +3,23 @@ import { SimfinityError } from '@simtlix/simfinity-core';
|
|
|
3
3
|
|
|
4
4
|
const MAX_TRANSIENT_RETRIES = 5;
|
|
5
5
|
const MAX_COMMIT_RETRIES = 5;
|
|
6
|
+
// Full-jitter exponential backoff before each complete-transaction retry: a uniform wait below
|
|
7
|
+
// 10, 20, 40, 80 and 160 ms, so conflicting transactions do not retry in lock-step.
|
|
8
|
+
const RETRY_BASE_DELAY_MS = 10;
|
|
9
|
+
const WRITE_CONFLICT = 112;
|
|
10
|
+
|
|
11
|
+
const retryDelay = (attempt) => Math.random() * RETRY_BASE_DELAY_MS * 2 ** attempt;
|
|
12
|
+
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
13
|
+
const isWriteConflict = (error) => error?.code === WRITE_CONFLICT || error?.codeName === 'WriteConflict';
|
|
14
|
+
|
|
15
|
+
// Same code and status as PostgreSQL's exhausted serialization and deadlock retries. The driver
|
|
16
|
+
// error stays available to logging, as on InternalServerError.
|
|
17
|
+
const retryExceeded = (error) => {
|
|
18
|
+
const exceeded = new SimfinityError('Concurrent write could not be completed', 'TRANSACTION_RETRY_EXCEEDED', 409);
|
|
19
|
+
exceeded.cause = error;
|
|
20
|
+
exceeded.getCause = () => exceeded.cause;
|
|
21
|
+
return exceeded;
|
|
22
|
+
};
|
|
6
23
|
|
|
7
24
|
const commitWithRetry = async (session) => {
|
|
8
25
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -45,7 +62,7 @@ export const withMongoTransaction = async (session, body, model, transactionOpti
|
|
|
45
62
|
? await mongoose.startSession() : await connection.startSession();
|
|
46
63
|
let failed = false;
|
|
47
64
|
try {
|
|
48
|
-
for (let attempt = 0;
|
|
65
|
+
for (let attempt = 0; ; attempt++) {
|
|
49
66
|
if (transactionOptions) await mySession.startTransaction(transactionOptions);
|
|
50
67
|
else await mySession.startTransaction();
|
|
51
68
|
try {
|
|
@@ -65,14 +82,16 @@ export const withMongoTransaction = async (session, body, model, transactionOpti
|
|
|
65
82
|
throw error;
|
|
66
83
|
}
|
|
67
84
|
}
|
|
68
|
-
|
|
69
|
-
if (
|
|
85
|
+
if (!error?.errorLabels?.includes('TransientTransactionError')) throw error;
|
|
86
|
+
if (attempt < MAX_TRANSIENT_RETRIES) {
|
|
87
|
+
// Wait only once the failed attempt is aborted, so its locks are not held meanwhile.
|
|
88
|
+
await sleep(retryDelay(attempt));
|
|
70
89
|
continue;
|
|
71
90
|
}
|
|
72
|
-
|
|
91
|
+
// Other exhausted transient failures (network, NoSuchTransaction) are not concurrency.
|
|
92
|
+
throw isWriteConflict(error) ? retryExceeded(error) : error;
|
|
73
93
|
}
|
|
74
94
|
}
|
|
75
|
-
throw new SimfinityError('Transaction exceeded retry limit', 'TRANSACTION_RETRY_EXCEEDED', 500);
|
|
76
95
|
} catch (error) {
|
|
77
96
|
failed = true;
|
|
78
97
|
throw error;
|
package/types/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
} from 'graphql';
|
|
15
15
|
import type {
|
|
16
16
|
DatabaseAdapter,
|
|
17
|
+
MutationLimitsOptions,
|
|
17
18
|
RuntimeRegistration,
|
|
18
19
|
SimfinityError,
|
|
19
20
|
} from '@simtlix/simfinity-core';
|
|
@@ -35,9 +36,14 @@ export type {
|
|
|
35
36
|
EnvelopSchemaPlugin,
|
|
36
37
|
FieldValidations,
|
|
37
38
|
FieldValidator,
|
|
39
|
+
MutationLimitsOptions,
|
|
38
40
|
PermissionSchema,
|
|
39
41
|
PolicyExpression,
|
|
42
|
+
ScopeFunction,
|
|
43
|
+
ScopeOperation,
|
|
44
|
+
ScopeParams,
|
|
40
45
|
TypePermissions,
|
|
46
|
+
TypeScopes,
|
|
41
47
|
} from '@simtlix/simfinity-core';
|
|
42
48
|
export * from '@simtlix/simfinity-mcp';
|
|
43
49
|
export { default as mcp } from '@simtlix/simfinity-mcp';
|
|
@@ -47,6 +53,14 @@ export interface MongoAdapterOptions {
|
|
|
47
53
|
}
|
|
48
54
|
export interface MongoAdapter extends DatabaseAdapter {
|
|
49
55
|
readonly referentialIntegrity: 'off' | 'transactional';
|
|
56
|
+
/**
|
|
57
|
+
* Accepts only an ObjectId or a 24-character hexadecimal string, normalized to lowercase, and
|
|
58
|
+
* throws `NOT_VALID_ID` (400) for anything else, including undefined, null and numbers. It never
|
|
59
|
+
* creates an identifier; use `new mongoose.Types.ObjectId()` for that.
|
|
60
|
+
*/
|
|
61
|
+
castId(value: any): any;
|
|
62
|
+
/** Returns null for a hydrated nested path whose stored value is an explicit null; otherwise the value. */
|
|
63
|
+
readEmbeddedValue(value: any): any;
|
|
50
64
|
/** After connecting MongoDB and createSchema(), await the transaction capability and existing-reference audit. */
|
|
51
65
|
initialize(): Promise<void>;
|
|
52
66
|
}
|
|
@@ -74,7 +88,12 @@ export interface StateMachine {
|
|
|
74
88
|
[key: string]: any;
|
|
75
89
|
}
|
|
76
90
|
|
|
77
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Register a Mongoose model + GraphQL type pair, exposing single and list endpoints.
|
|
93
|
+
* Throws INVALID_SCOPE (500) for an invalid `extensions.scope`, and TYPE_BOUND_TO_OTHER_RUNTIME
|
|
94
|
+
* (409) when another runtime bound the type by generating its relation resolvers, or reserved it
|
|
95
|
+
* because its schema reached the type with an unresolved relation field.
|
|
96
|
+
*/
|
|
78
97
|
export function connect(
|
|
79
98
|
model: any,
|
|
80
99
|
gqltype: GraphQLObjectType,
|
|
@@ -85,13 +104,21 @@ export function connect(
|
|
|
85
104
|
stateMachine?: StateMachine | null,
|
|
86
105
|
): void;
|
|
87
106
|
|
|
88
|
-
/** Register a GraphQL type without exposing root endpoints (e.g. embedded/related types). */
|
|
107
|
+
/** Register a GraphQL type without exposing root endpoints (e.g. embedded/related types). Throws like connect(). */
|
|
89
108
|
export function addNoEndpointType(gqltype: GraphQLObjectType): void;
|
|
90
109
|
|
|
91
110
|
/**
|
|
92
111
|
* Build the executable GraphQLSchema from every connected type. The first two
|
|
93
112
|
* allowlists are matched against the GraphQLObjectType INSTANCES passed to
|
|
94
|
-
* connect(); only custom mutations are matched by name.
|
|
113
|
+
* connect(); only custom mutations are matched by name. Scopes are validated
|
|
114
|
+
* (INVALID_SCOPE), and reachable types bound or reserved by another runtime, or
|
|
115
|
+
* holding a field copied with toConfig() after another runtime generated its
|
|
116
|
+
* relation resolver, keeping the generated resolver or its extensions, are rejected (TYPE_BOUND_TO_OTHER_RUNTIME), before models
|
|
117
|
+
* are created; a list or embedded relation to an unregistered type throws
|
|
118
|
+
* UNREGISTERED_RELATION_TARGET. A writable non-embedded list relation without a child
|
|
119
|
+
* connectionField, whose resolver Simfinity would generate, throws INVALID_MODEL (400). In default
|
|
120
|
+
* mode, one with its own resolver or readOnly only logs a warning and rejects nested writes
|
|
121
|
+
* (INVALID_MODEL, 500); referentialIntegrity 'transactional' rejects every such collection.
|
|
95
122
|
*/
|
|
96
123
|
export function createSchema(
|
|
97
124
|
includedQueryTypes?: GraphQLObjectType[] | null,
|
|
@@ -122,6 +149,10 @@ export interface SimfinityMiddlewareContext {
|
|
|
122
149
|
* Register middleware before generated operations, including related-type reads and nested child writes.
|
|
123
150
|
* Nested operations keep root argument shapes and the GraphQL request context.
|
|
124
151
|
* Await next() to advance the middleware chain; throw to reject before the operation executes.
|
|
152
|
+
* A non-function throws INVALID_MIDDLEWARE (500). The rest of the chain runs at most once and is
|
|
153
|
+
* awaited even when next() is not; its errors cancel the operation, even if a middleware catches them.
|
|
154
|
+
* Call next() before the middleware returns or its promise settles: a later call, such as
|
|
155
|
+
* setTimeout(next), does nothing, so the remaining middleware is skipped.
|
|
125
156
|
*/
|
|
126
157
|
export function use(
|
|
127
158
|
middleware: (
|
|
@@ -142,6 +173,15 @@ export interface QueryLimitsOptions {
|
|
|
142
173
|
/** Configure at startup. Invalid configuration throws INVALID_QUERY_LIMITS (400). */
|
|
143
174
|
export function configureQueryLimits(options?: QueryLimitsOptions): void;
|
|
144
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Process-wide limit on nested added/updated/deleted entries per generated add, update or
|
|
178
|
+
* state-action mutation; unlimited by default, restored by calling it without options.
|
|
179
|
+
* Invalid configuration, including an unknown or misspelled option key, throws
|
|
180
|
+
* INVALID_MUTATION_LIMITS (400) and keeps the current limit; a mutation over the limit
|
|
181
|
+
* fails with NESTED_OPERATIONS_EXCEEDED (400) before its transaction starts.
|
|
182
|
+
*/
|
|
183
|
+
export function configureMutationLimits(options?: MutationLimitsOptions): void;
|
|
184
|
+
|
|
145
185
|
/**
|
|
146
186
|
* Get the generated create input type, preserving field and list-item non-null
|
|
147
187
|
* wrappers. Update inputs remove only the outer wrapper (except entity id).
|
|
@@ -157,7 +197,10 @@ export function getInputType(type: GraphQLObjectType | { name: string }): GraphQ
|
|
|
157
197
|
* The caller then owns retries, commit, abort, and cleanup; inactive sessions are rejected.
|
|
158
198
|
* Exception: transactional reference integrity aborts on a violation, guarded-write error or ambiguous update result.
|
|
159
199
|
* That mode requires snapshot read concern and majority write concern on supplied transactions.
|
|
160
|
-
* Owned transactions retry transient failures
|
|
200
|
+
* Owned transactions retry transient failures after a short randomized backoff, and uncertain commits
|
|
201
|
+
* immediately, up to five times each. An exhausted write conflict throws TRANSACTION_RETRY_EXCEEDED
|
|
202
|
+
* (409) with the driver error as `cause`; a supplied session receives the raw driver error.
|
|
203
|
+
* Relation inputs need `{ id }`; a missing or malformed ID throws NOT_VALID_ID (400).
|
|
161
204
|
*/
|
|
162
205
|
|
|
163
206
|
export function saveObject(
|
|
@@ -192,7 +235,12 @@ export interface FilterGroupInput {
|
|
|
192
235
|
[key: string]: any;
|
|
193
236
|
}
|
|
194
237
|
|
|
195
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* Translate list-query arguments (filters, AND/OR, sort, pagination) into a MongoDB aggregation
|
|
240
|
+
* pipeline. `input.aggregation` filters a field named `aggregation`; the aggregate stage is never
|
|
241
|
+
* built here. An embedded `.id` path uses the declared `id` member, except for entity types embedded
|
|
242
|
+
* as lists and supplied subdocument schemas without an `id` path, which use the subdocument `_id`.
|
|
243
|
+
*/
|
|
196
244
|
export function buildQuery(
|
|
197
245
|
input: Record<string, any>,
|
|
198
246
|
gqltype: GraphQLObjectType,
|