@simtlix/simfinity-js 3.6.0 → 3.8.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 +15 -0
- package/package.json +3 -3
- package/src/mongo/adapter.js +97 -0
- package/src/mongo/integrity.js +5 -2
- package/src/mongo/models.js +20 -5
- package/src/mongo/queries.js +7 -1
- package/types/index.d.ts +39 -2
package/README.md
CHANGED
|
@@ -24,10 +24,25 @@ 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`. Embedded types that contain each other are rejected with `INVALID_MODEL` (`Embedded cycle at …`) when a generated model reaches them, and always in transactional mode; in the default mode, a cycle that only supplied models reach is not checked. 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
|
+
Virtual fields (`extensions: { virtual: true }`, with the field's own `resolve`) get no path, no `[]` default and no index in generated models, on entities and embedded types, and no entry in the transactional integrity registry, so they need no `relation` in either mode and documents look the same with or without them. Generated writes never set or unset them, and Mongoose's strict mode drops a value that a controller assigns to a generated document for one. An embedded type whose members are all virtual becomes a strict subdocument without paths rather than a Mixed path, so nothing is stored for it but the `_id` of list items. With a generated model, values stored before a field became virtual stay in the documents: Mongoose drops an `$unset` of a path the model does not declare, so remove them with a driver-level update, such as `getModel(Type).collection.updateMany({}, { $unset: { busyRanges: '' } })`, or with `updateMany(filter, update, { strict: false })`; a generated update that sets an embedded parent rewrites it, which drops the stored keys of its virtual members. A supplied model keeps the paths it declares: documents stored earlier keep their values for the field, which nothing reads or writes until `$unset` removes them, and an index on such a path stays until you drop it. `buildQuery()` rejects a path through a virtual field with `INVALID_FILTER_FIELD` (400).
|
|
28
|
+
|
|
27
29
|
The facade registers generated models on your application's default Mongoose instance and does not change global Mongoose options such as `strictQuery`. Supplied models may use another connection, such as one from `mongoose.createConnection()`: generated mutations and `saveObject()` use the model's connection, and, in the default `referentialIntegrity: 'off'` mode, custom mutations use the default connection unless it is unused, never opened and without compiled models, also on its `useDb()` descendants, and the registered models share one MongoDB client; with transactional reference integrity they use the first protected model's connection. `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
30
|
|
|
29
31
|
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.
|
|
30
32
|
|
|
33
|
+
## Optional batched collection reads
|
|
34
|
+
|
|
35
|
+
Generated collection fields read one parent at a time by default. With `batchedCollections: true`, the adapter reads the collection fields of many parents that share their arguments with aggregate commands of up to `collectionBatchSize` parents each (an integer from 1 to 100, default 5), sent concurrently:
|
|
36
|
+
|
|
37
|
+
```javascript
|
|
38
|
+
import { createMongoAdapter, createRuntime } from '@simtlix/simfinity-js';
|
|
39
|
+
|
|
40
|
+
const adapter = createMongoAdapter({ batchedCollections: true }); // collectionBatchSize: 5
|
|
41
|
+
const simfinity = createRuntime(adapter);
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Each parent's own pipeline (filters, sort and page) runs as one sub-pipeline: the first is the command's pipeline and the others are `$unionWith` stages. Like `referentialIntegrity`, the option needs your own adapter and runtime; the package-root runtime (`simfinity.connect()` and the rest) keeps one read per parent. Children of models with `aggregate` middleware, their own `aggregate` static, a discriminator child model or a wrapped `Model.collection.aggregate`, reads inside a `transactionAsyncLocalStorage` transaction, and adapter copies or overrides of `findChildren` keep one read per parent. A command runs its sub-pipelines one after another, so fewer commands can come with a slower single request, and command monitoring, query settings and `$queryStats` see the new command shape: read the [upgrade notes](https://simtlix.github.io/simfinity.js/resources/compatibility.html#upgrade-to-3-7-0) before enabling it. Invalid values throw `INVALID_MONGO_ADAPTER_OPTIONS` (400).
|
|
45
|
+
|
|
31
46
|
## Optional transactional reference integrity
|
|
32
47
|
|
|
33
48
|
Choose the mode once when creating an adapter; the default remains `'off'`:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
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.
|
|
33
|
-
"@simtlix/simfinity-mcp": "3.
|
|
32
|
+
"@simtlix/simfinity-core": "3.8.0",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.8.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
package/src/mongo/adapter.js
CHANGED
|
@@ -53,6 +53,37 @@ const hasNoQueryHooks = (Model, operations) => hasNoHooks(Model.schema?.s?.hooks
|
|
|
53
53
|
// getById reads through `findOne` and getByIds through `find`.
|
|
54
54
|
const batchesFindOne = (Model) => hasNoQueryHooks(Model, ['find', 'findOne']);
|
|
55
55
|
|
|
56
|
+
// Batched collection reads (findChildrenBatch), opt in with `batchedCollections`. Each parent's own
|
|
57
|
+
// findChildren pipeline runs as one sub-pipeline of an aggregate command, the first as the command's
|
|
58
|
+
// pipeline and the others in $unionWith stages, at most `collectionBatchSize` parents per command;
|
|
59
|
+
// the commands of one call run concurrently. A command runs its sub-pipelines one after another.
|
|
60
|
+
const DEFAULT_COLLECTION_BATCH_SIZE = 5;
|
|
61
|
+
const MAX_COLLECTION_BATCH_SIZE = 100;
|
|
62
|
+
// Sub-pipeline bytes per command, well below MongoDB's 16 MiB command limit.
|
|
63
|
+
const COLLECTION_COMMAND_BYTES = 8 * 1024 * 1024;
|
|
64
|
+
const invalidAdapterOption = (message) => new SimfinityError(message, 'INVALID_MONGO_ADAPTER_OPTIONS', 400);
|
|
65
|
+
const readCollectionBatching = (options) => {
|
|
66
|
+
const { batchedCollections = false, collectionBatchSize = DEFAULT_COLLECTION_BATCH_SIZE } = options ?? {};
|
|
67
|
+
if (typeof batchedCollections !== 'boolean') throw invalidAdapterOption('batchedCollections must be true or false');
|
|
68
|
+
if (!Number.isSafeInteger(collectionBatchSize) || collectionBatchSize < 1 || collectionBatchSize > MAX_COLLECTION_BATCH_SIZE) {
|
|
69
|
+
throw invalidAdapterOption(`collectionBatchSize must be an integer from 1 to ${MAX_COLLECTION_BATCH_SIZE}`);
|
|
70
|
+
}
|
|
71
|
+
return { enabled: batchedCollections, parentsPerCommand: collectionBatchSize };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Mongoose applies aggregate middleware, a model's own aggregate static (schema.static, loadClass, or
|
|
75
|
+
// a model from another Mongoose copy), a discriminator's key and a wrapper of the model's
|
|
76
|
+
// collection.aggregate to the command's pipeline only, never to $unionWith sub-pipelines, so such
|
|
77
|
+
// models read their children one parent at a time.
|
|
78
|
+
const batchesAggregate = (Model) => Model?.aggregate === mongoose.Model.aggregate
|
|
79
|
+
&& hasNoHooks(Model.hooks, ['aggregate']) && hasNoHooks(Model.schema?.s?.hooks, ['aggregate'])
|
|
80
|
+
&& !(Model.schema?.discriminatorMapping && !Model.schema.discriminatorMapping.isRoot)
|
|
81
|
+
&& Model.collection != null && !Object.hasOwn(Model.collection, 'aggregate');
|
|
82
|
+
// MongoDB rejects $unionWith in a multi-document transaction, and the rejection aborts the
|
|
83
|
+
// transaction. Inside connection.transaction() with Mongoose's transactionAsyncLocalStorage, an
|
|
84
|
+
// aggregate without a session joins the transaction.
|
|
85
|
+
const inTransactionStore = (Model) => Model?.db?.base?.transactionAsyncLocalStorage?.getStore()?.session != null;
|
|
86
|
+
|
|
56
87
|
// The statics that getById, update and delete call, directly or through each other.
|
|
57
88
|
const BY_ID_STATICS = ['findById', 'findOne', 'findByIdAndUpdate', 'findOneAndUpdate', 'findByIdAndDelete', 'findOneAndDelete'];
|
|
58
89
|
// Mongoose's automatic ObjectId setter only changes document assignments, never query filters.
|
|
@@ -128,6 +159,7 @@ const nestedPathOf = (nested) => {
|
|
|
128
159
|
|
|
129
160
|
export const createMongoAdapter = (options) => {
|
|
130
161
|
const integrity = createMongoIntegrity(options);
|
|
162
|
+
const collectionBatching = readCollectionBatching(options);
|
|
131
163
|
let queries;
|
|
132
164
|
let getRegistrations = () => [];
|
|
133
165
|
const privateConnectionFields = new Map();
|
|
@@ -347,6 +379,71 @@ export const createMongoAdapter = (options) => {
|
|
|
347
379
|
},
|
|
348
380
|
};
|
|
349
381
|
|
|
382
|
+
// Without the option the adapter has neither batching member, so the runtime reads each
|
|
383
|
+
// collection field with findChildren, as 3.6.0 did.
|
|
384
|
+
if (collectionBatching.enabled) {
|
|
385
|
+
const ownFindChildren = adapter.findChildren;
|
|
386
|
+
// Why `self` (the adapter the runtime called) cannot batch the children of `Model`, or null. A
|
|
387
|
+
// batch reproduces only this adapter's own findChildren, so a copy or an override of findChildren
|
|
388
|
+
// reads one parent at a time with that findChildren.
|
|
389
|
+
const declineReason = (self, Model, session) => {
|
|
390
|
+
if (self?.findChildren !== ownFindChildren) return 'its findChildren is not the adapter\'s own';
|
|
391
|
+
if (!batchesAggregate(Model)) {
|
|
392
|
+
return 'its aggregate reads have middleware, an own aggregate static, a discriminator or a wrapped collection.aggregate';
|
|
393
|
+
}
|
|
394
|
+
if (session != null || inTransactionStore(Model)) return 'it reads in a session or a transaction';
|
|
395
|
+
return null;
|
|
396
|
+
};
|
|
397
|
+
adapter.batchesChildren = function batchesChildren(Model) {
|
|
398
|
+
return declineReason(this, Model, null) === null;
|
|
399
|
+
};
|
|
400
|
+
// One array per parent ID, in order, each equal to findChildren's result for that parent. It
|
|
401
|
+
// declines by throwing before any command, and fails as a whole, never with partial results.
|
|
402
|
+
adapter.findChildrenBatch = async function findChildrenBatch(Model, gqltype, connectionField, parentIds, args, batchOptions) {
|
|
403
|
+
integrity.assertReady();
|
|
404
|
+
const reason = declineReason(this, Model, batchOptions?.session);
|
|
405
|
+
if (reason) throw new Error(`${Model?.modelName} reads children one parent at a time: ${reason}`);
|
|
406
|
+
const pipeline = await queries.buildQuery(args, gqltype);
|
|
407
|
+
const schemaType = Model.schema.path(connectionField);
|
|
408
|
+
const lists = parentIds.map(() => []);
|
|
409
|
+
const subPipelines = [];
|
|
410
|
+
parentIds.forEach((parentId, index) => {
|
|
411
|
+
let parentKey;
|
|
412
|
+
try {
|
|
413
|
+
parentKey = castPathValue(schemaType, parentId);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
// As in findChildren: a parent key that the connection path cannot cast has no children.
|
|
416
|
+
if (error?.extensions?.code === 'NOT_VALID_ID') return;
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
// Wrapping each row, instead of adding a field, cannot collide with a stored field.
|
|
420
|
+
subPipelines.push([{ $match: { [connectionField]: parentKey } }, ...pipeline, { $replaceWith: { i: index, d: '$$ROOT' } }]);
|
|
421
|
+
});
|
|
422
|
+
if (subPipelines.length === 0) return lists;
|
|
423
|
+
const parentsPerCommand = Math.max(1, Math.min(collectionBatching.parentsPerCommand,
|
|
424
|
+
Math.floor(COLLECTION_COMMAND_BYTES / mongoose.mongo.BSON.calculateObjectSize({ pipeline: subPipelines[0] }))));
|
|
425
|
+
const limit = pipeline.findLast((stage) => Object.hasOwn(stage, '$limit'))?.$limit;
|
|
426
|
+
const coll = Model.collection.collectionName;
|
|
427
|
+
const commands = [];
|
|
428
|
+
for (let start = 0; start < subPipelines.length; start += parentsPerCommand) {
|
|
429
|
+
const [first, ...others] = subPipelines.slice(start, start + parentsPerCommand);
|
|
430
|
+
const command = Model.aggregate([...first, ...others.map((subPipeline) => ({ $unionWith: { coll, pipeline: subPipeline } }))]);
|
|
431
|
+
// One more than the rows the command can return, so that its first batch closes the cursor.
|
|
432
|
+
if (Number.isSafeInteger(limit)) command.option({ batchSize: limit * (others.length + 1) + 1 });
|
|
433
|
+
commands.push(command);
|
|
434
|
+
}
|
|
435
|
+
// Every command settles before a failure is reported, so per-parent reads that follow it do not
|
|
436
|
+
// overlap the remaining commands.
|
|
437
|
+
const outcomes = await Promise.allSettled(commands);
|
|
438
|
+
const failure = outcomes.find(({ status }) => status === 'rejected');
|
|
439
|
+
if (failure) throw failure.reason;
|
|
440
|
+
for (const { value: rows } of outcomes) {
|
|
441
|
+
for (const { i, d } of rows) lists[i].push(d);
|
|
442
|
+
}
|
|
443
|
+
return lists;
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
350
447
|
Object.defineProperty(adapter, 'referentialIntegrity', { value: integrity.mode, enumerable: true });
|
|
351
448
|
return adapter;
|
|
352
449
|
};
|
package/src/mongo/integrity.js
CHANGED
|
@@ -26,10 +26,13 @@ const valuesAt = (value, path) => {
|
|
|
26
26
|
return valuesAt(value[path[0]], path.slice(1));
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
// createMongoAdapter's options: the adapter reads the collection batching ones.
|
|
30
|
+
const ADAPTER_OPTIONS = ['referentialIntegrity', 'batchedCollections', 'collectionBatchSize'];
|
|
31
|
+
|
|
29
32
|
export const createMongoIntegrity = (options = {}) => {
|
|
30
33
|
if (!options || typeof options !== 'object' || Array.isArray(options)
|
|
31
|
-
|| Object.keys(options).some((key) => key
|
|
32
|
-
throw invalid('Expected Mongo adapter options
|
|
34
|
+
|| Object.keys(options).some((key) => !ADAPTER_OPTIONS.includes(key))) {
|
|
35
|
+
throw invalid('Expected Mongo adapter options among referentialIntegrity, batchedCollections and collectionBatchSize');
|
|
33
36
|
}
|
|
34
37
|
const mode = options.referentialIntegrity === undefined ? 'off' : options.referentialIntegrity;
|
|
35
38
|
if (!['off', 'transactional'].includes(mode)) {
|
package/src/mongo/models.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from 'graphql';
|
|
13
13
|
import mongoose from 'mongoose';
|
|
14
14
|
import { SimfinityError } from '@simtlix/simfinity-core';
|
|
15
|
-
import { getFieldStorageName } from '@simtlix/simfinity-core/internal/relation-storage';
|
|
15
|
+
import { getFieldStorageName, isVirtualField } from '@simtlix/simfinity-core/internal/relation-storage';
|
|
16
16
|
|
|
17
17
|
const isNonNullOfType = (fieldEntryType, graphQLType) => (
|
|
18
18
|
fieldEntryType instanceof GraphQLNonNull && fieldEntryType.ofType instanceof graphQLType
|
|
@@ -97,11 +97,24 @@ const assertNoEmbeddedCycle = (gqlType, entryType, ancestors, fieldPath) => {
|
|
|
97
97
|
if (ancestors.includes(entryType)) throw new SimfinityError(`Embedded cycle at ${fieldPath}`, 'INVALID_MODEL', 400);
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
+
// Mongoose reads an empty definition, `{}` or `[{}]`, as Mixed, which stores any keys. An embedded
|
|
101
|
+
// type whose members are all virtual has such a definition, so it gets a strict subdocument without
|
|
102
|
+
// paths instead: no value of its virtual members, or any other key, is stored. A list item keeps its
|
|
103
|
+
// `_id`, as other embedded list items do. Embedded types without virtual members keep their
|
|
104
|
+
// definition, even an empty one.
|
|
105
|
+
const storedEmbeddedDefinition = (entryType, definition, list) => {
|
|
106
|
+
if (Object.keys(definition).length || !Object.values(entryType.getFields()).some(isVirtualField)) return definition;
|
|
107
|
+
return list ? new mongoose.Schema({}) : new mongoose.Schema({}, { _id: false });
|
|
108
|
+
};
|
|
109
|
+
|
|
100
110
|
const generateSchemaDefinition = (gqlType, nested = false, ancestors = [], path = gqlType.name) => {
|
|
101
111
|
const argTypes = gqlType.getFields();
|
|
102
112
|
const schemaArg = {};
|
|
103
113
|
|
|
104
114
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
115
|
+
// A virtual field is resolved by its own resolver and stored nowhere, on entities and embedded
|
|
116
|
+
// types alike: it gets no path, so no `[]` default and no ObjectId index either.
|
|
117
|
+
if (isVirtualField(fieldEntry)) continue;
|
|
105
118
|
const { type } = fieldEntry;
|
|
106
119
|
|
|
107
120
|
if (matchesScalar(type, GraphQLID)) {
|
|
@@ -123,9 +136,10 @@ const generateSchemaDefinition = (gqlType, nested = false, ancestors = [], path
|
|
|
123
136
|
const entryType = unwrapNonNull(type);
|
|
124
137
|
const fieldPath = `${path}.${fieldEntryName}`;
|
|
125
138
|
assertNoEmbeddedCycle(gqlType, entryType, ancestors, fieldPath);
|
|
126
|
-
const definition =
|
|
139
|
+
const definition = storedEmbeddedDefinition(entryType,
|
|
140
|
+
generateSchemaDefinition(entryType, true, [...ancestors, gqlType], fieldPath), false);
|
|
127
141
|
// A nested object under a `type` key can only be a subdocument; like other embedded objects, it has no _id.
|
|
128
|
-
schemaArg[fieldEntryName] = nested && fieldEntryName === 'type'
|
|
142
|
+
schemaArg[fieldEntryName] = nested && fieldEntryName === 'type' && !(definition instanceof mongoose.Schema)
|
|
129
143
|
? new mongoose.Schema(definition, { _id: false })
|
|
130
144
|
: definition;
|
|
131
145
|
}
|
|
@@ -137,7 +151,8 @@ const generateSchemaDefinition = (gqlType, nested = false, ancestors = [], path
|
|
|
137
151
|
assertEmbeddedFieldName(gqlType, fieldEntryName);
|
|
138
152
|
const fieldPath = `${path}.${fieldEntryName}`;
|
|
139
153
|
assertNoEmbeddedCycle(gqlType, itemType, ancestors, fieldPath);
|
|
140
|
-
schemaArg[fieldEntryName] = [
|
|
154
|
+
schemaArg[fieldEntryName] = [storedEmbeddedDefinition(itemType,
|
|
155
|
+
generateSchemaDefinition(itemType, true, [...ancestors, gqlType], fieldPath), true)];
|
|
141
156
|
}
|
|
142
157
|
} else if (listItemMatchesScalar(type, GraphQLID)) {
|
|
143
158
|
schemaArg[fieldEntryName] = [mongoose.Schema.Types.ObjectId];
|
|
@@ -186,7 +201,7 @@ const findObjectIdFields = (schemaDefinition, parentPath = '') => {
|
|
|
186
201
|
if (definition === mongoose.Schema.Types.ObjectId) {
|
|
187
202
|
objectIdFields.push(currentPath);
|
|
188
203
|
} else if (Array.isArray(definition)) {
|
|
189
|
-
const arrayElement = definition[0];
|
|
204
|
+
const arrayElement = definition[0] instanceof mongoose.Schema ? definition[0].obj : definition[0];
|
|
190
205
|
if (typeof arrayElement === 'object' && arrayElement !== null) {
|
|
191
206
|
objectIdFields.push(...findObjectIdFields(arrayElement, currentPath));
|
|
192
207
|
}
|
package/src/mongo/queries.js
CHANGED
|
@@ -9,7 +9,7 @@ import mongoose from 'mongoose';
|
|
|
9
9
|
|
|
10
10
|
import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity-core';
|
|
11
11
|
import {
|
|
12
|
-
getFieldStorageName, getListShape, hasStoredIdentity, normalizeConnectionField,
|
|
12
|
+
getFieldStorageName, getListShape, hasStoredIdentity, isVirtualField, normalizeConnectionField,
|
|
13
13
|
} from '@simtlix/simfinity-core/internal/relation-storage';
|
|
14
14
|
|
|
15
15
|
import { castObjectId } from './ids.js';
|
|
@@ -140,6 +140,12 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
|
140
140
|
for (const [index, part] of parts.entries()) {
|
|
141
141
|
const field = Object.hasOwn(currentType.getFields(), part) ? currentType.getFields()[part] : null;
|
|
142
142
|
if (!field) throw filterError(`Unknown query field: ${path}`, 'INVALID_FILTER_FIELD');
|
|
143
|
+
// Core rejects generated and trusted paths through a virtual field before the adapter runs. A
|
|
144
|
+
// native buildQuery() or buildFilterGroupMatch() call fails here, instead of matching storage
|
|
145
|
+
// that does not exist.
|
|
146
|
+
if (isVirtualField(field)) {
|
|
147
|
+
throw filterError(`Query path ${path} uses the virtual field ${currentType.name}.${part}, which is not stored`, 'INVALID_FILTER_FIELD');
|
|
148
|
+
}
|
|
143
149
|
const fieldType = getNamedType(field.type);
|
|
144
150
|
const relation = field.extensions?.relation;
|
|
145
151
|
if (fieldType instanceof GraphQLObjectType) {
|
package/types/index.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export type {
|
|
|
33
33
|
AuthPluginOptions,
|
|
34
34
|
AuthRule,
|
|
35
35
|
AuthRuleFunction,
|
|
36
|
+
AuthSchemaPlugin,
|
|
36
37
|
EnvelopSchemaPlugin,
|
|
37
38
|
FieldValidations,
|
|
38
39
|
FieldValidator,
|
|
@@ -45,12 +46,36 @@ export type {
|
|
|
45
46
|
ScopeParams,
|
|
46
47
|
TypePermissions,
|
|
47
48
|
TypeScopes,
|
|
49
|
+
VirtualFieldDeclaration,
|
|
50
|
+
VirtualFieldOptions,
|
|
48
51
|
} from '@simtlix/simfinity-core';
|
|
49
52
|
export * from '@simtlix/simfinity-mcp';
|
|
50
53
|
export { default as mcp } from '@simtlix/simfinity-mcp';
|
|
51
54
|
export interface MongoAdapterOptions {
|
|
52
55
|
/** Fixed at adapter creation. Defaults to off; transactional mode requires initialize(). */
|
|
53
56
|
referentialIntegrity?: 'off' | 'transactional';
|
|
57
|
+
/**
|
|
58
|
+
* Opt in to batched collection reads; defaults to false. When true, the adapter defines
|
|
59
|
+
* `findChildrenBatch` and `batchesChildren`, so the runtime reads the generated collection fields
|
|
60
|
+
* of one request that share a related type, a connection field and their arguments with aggregate
|
|
61
|
+
* commands of up to `collectionBatchSize` parents each, run concurrently. Each parent's own
|
|
62
|
+
* pipeline (filters, sort and page) runs as one sub-pipeline of a command, in a `$unionWith` stage
|
|
63
|
+
* after the first. Children of models with aggregate middleware, their own `aggregate` static, a
|
|
64
|
+
* discriminator key or a wrapped `collection.aggregate`, reads inside a Mongoose
|
|
65
|
+
* `transactionAsyncLocalStorage` transaction, and adapter copies or overrides of `findChildren`
|
|
66
|
+
* keep one read per parent. When false, the adapter has neither member and reads one parent at a
|
|
67
|
+
* time, as 3.6.0 did. Fixed at adapter creation; a value other than a boolean throws
|
|
68
|
+
* `INVALID_MONGO_ADAPTER_OPTIONS` (400).
|
|
69
|
+
*/
|
|
70
|
+
batchedCollections?: boolean | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Parents per batched aggregate command, an integer from 1 to 100; defaults to 5. A command runs
|
|
73
|
+
* its parents' sub-pipelines one after another, and the commands of one batch run concurrently, so
|
|
74
|
+
* a larger value sends fewer commands at the cost of a longer command. Only used with
|
|
75
|
+
* `batchedCollections: true`. Fixed at adapter creation; another value throws
|
|
76
|
+
* `INVALID_MONGO_ADAPTER_OPTIONS` (400).
|
|
77
|
+
*/
|
|
78
|
+
collectionBatchSize?: number | undefined;
|
|
54
79
|
}
|
|
55
80
|
/**
|
|
56
81
|
* In the default 'off' mode, withTransaction(null, callback) without a model, as custom mutations
|
|
@@ -58,7 +83,8 @@ export interface MongoAdapterOptions {
|
|
|
58
83
|
* client, no model is compiled on it or on its useDb() descendants, and every model of the bound
|
|
59
84
|
* runtime's registrations uses one MongoDB client: then it opens it on that client. A supplied
|
|
60
85
|
* session or model is used as is. In transactional mode it uses the first protected model's
|
|
61
|
-
* connection.
|
|
86
|
+
* connection. With `batchedCollections: true` the adapter also has the optional `DatabaseAdapter`
|
|
87
|
+
* members `findChildrenBatch` and `batchesChildren`; without it, it has neither.
|
|
62
88
|
*/
|
|
63
89
|
export interface MongoAdapter extends DatabaseAdapter {
|
|
64
90
|
readonly referentialIntegrity: 'off' | 'transactional';
|
|
@@ -139,6 +165,13 @@ export function addNoEndpointType(gqltype: GraphQLObjectType): void;
|
|
|
139
165
|
* Embedded types that contain each other throw INVALID_MODEL (400, `Embedded cycle at …`) when a
|
|
140
166
|
* generated model's embedded fields reach them, and always in transactional mode; in default mode a
|
|
141
167
|
* cycle that only supplied models reach is not checked.
|
|
168
|
+
* Invalid virtual field declarations (`extensions.virtual`, see `VirtualFieldOptions`) throw
|
|
169
|
+
* INVALID_MODEL (400) before models are created, in both referentialIntegrity modes. A virtual field
|
|
170
|
+
* gets no path, `[]` default or index in generated models, on entities and embedded types, and no
|
|
171
|
+
* entry in the transactional integrity registry, so it needs no `relation` in either mode; generated
|
|
172
|
+
* writes never set or unset it. A supplied model keeps the paths it declares. A readOnly field with
|
|
173
|
+
* arguments and its own resolver that is not virtual, and is not a reference or collection, logs a
|
|
174
|
+
* `Configuration issue` warning once.
|
|
142
175
|
* A generated input that would have no fields is left out with a warning, together with the
|
|
143
176
|
* collection operation, embedded field or add/update/state-action mutation that would take it; a
|
|
144
177
|
* non-null embedded field whose embedded type has no writable fields throws INVALID_MODEL (400)
|
|
@@ -272,6 +305,7 @@ export interface FilterGroupInput {
|
|
|
272
305
|
* pipeline. `input.aggregation` filters a field named `aggregation`; the aggregate stage is never
|
|
273
306
|
* built here. An embedded `.id` path uses the declared `id` member, except for entity types embedded
|
|
274
307
|
* as lists and supplied subdocument schemas without an `id` path, which use the subdocument `_id`.
|
|
308
|
+
* A filter, condition or sort path through a virtual field rejects with `INVALID_FILTER_FIELD` (400).
|
|
275
309
|
*/
|
|
276
310
|
export function buildQuery(
|
|
277
311
|
input: Record<string, any>,
|
|
@@ -279,7 +313,10 @@ export function buildQuery(
|
|
|
279
313
|
isCount?: boolean,
|
|
280
314
|
): Promise<Array<Record<string, any>>>;
|
|
281
315
|
|
|
282
|
-
/**
|
|
316
|
+
/**
|
|
317
|
+
* Translate a QLFilterGroup into a MongoDB `$match` expression, collecting required lookups. A
|
|
318
|
+
* condition path through a virtual field rejects with `INVALID_FILTER_FIELD` (400).
|
|
319
|
+
*/
|
|
283
320
|
export function buildFilterGroupMatch(
|
|
284
321
|
filterGroup: FilterGroupInput,
|
|
285
322
|
gqltype: GraphQLObjectType,
|