@simtlix/simfinity-js 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,19 @@ The facade registers generated models on your application's default Mongoose ins
28
28
 
29
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.
30
30
 
31
+ ## Optional batched collection reads
32
+
33
+ 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:
34
+
35
+ ```javascript
36
+ import { createMongoAdapter, createRuntime } from '@simtlix/simfinity-js';
37
+
38
+ const adapter = createMongoAdapter({ batchedCollections: true }); // collectionBatchSize: 5
39
+ const simfinity = createRuntime(adapter);
40
+ ```
41
+
42
+ 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).
43
+
31
44
  ## Optional transactional reference integrity
32
45
 
33
46
  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.6.0",
3
+ "version": "3.7.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.6.0",
33
- "@simtlix/simfinity-mcp": "3.6.0"
32
+ "@simtlix/simfinity-core": "3.7.0",
33
+ "@simtlix/simfinity-mcp": "3.7.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
@@ -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
  };
@@ -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 !== 'referentialIntegrity')) {
32
- throw invalid('Expected Mongo adapter options with referentialIntegrity');
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/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,
@@ -51,6 +52,28 @@ export { default as mcp } from '@simtlix/simfinity-mcp';
51
52
  export interface MongoAdapterOptions {
52
53
  /** Fixed at adapter creation. Defaults to off; transactional mode requires initialize(). */
53
54
  referentialIntegrity?: 'off' | 'transactional';
55
+ /**
56
+ * Opt in to batched collection reads; defaults to false. When true, the adapter defines
57
+ * `findChildrenBatch` and `batchesChildren`, so the runtime reads the generated collection fields
58
+ * of one request that share a related type, a connection field and their arguments with aggregate
59
+ * commands of up to `collectionBatchSize` parents each, run concurrently. Each parent's own
60
+ * pipeline (filters, sort and page) runs as one sub-pipeline of a command, in a `$unionWith` stage
61
+ * after the first. Children of models with aggregate middleware, their own `aggregate` static, a
62
+ * discriminator key or a wrapped `collection.aggregate`, reads inside a Mongoose
63
+ * `transactionAsyncLocalStorage` transaction, and adapter copies or overrides of `findChildren`
64
+ * keep one read per parent. When false, the adapter has neither member and reads one parent at a
65
+ * time, as 3.6.0 did. Fixed at adapter creation; a value other than a boolean throws
66
+ * `INVALID_MONGO_ADAPTER_OPTIONS` (400).
67
+ */
68
+ batchedCollections?: boolean | undefined;
69
+ /**
70
+ * Parents per batched aggregate command, an integer from 1 to 100; defaults to 5. A command runs
71
+ * its parents' sub-pipelines one after another, and the commands of one batch run concurrently, so
72
+ * a larger value sends fewer commands at the cost of a longer command. Only used with
73
+ * `batchedCollections: true`. Fixed at adapter creation; another value throws
74
+ * `INVALID_MONGO_ADAPTER_OPTIONS` (400).
75
+ */
76
+ collectionBatchSize?: number | undefined;
54
77
  }
55
78
  /**
56
79
  * In the default 'off' mode, withTransaction(null, callback) without a model, as custom mutations
@@ -58,7 +81,8 @@ export interface MongoAdapterOptions {
58
81
  * client, no model is compiled on it or on its useDb() descendants, and every model of the bound
59
82
  * runtime's registrations uses one MongoDB client: then it opens it on that client. A supplied
60
83
  * session or model is used as is. In transactional mode it uses the first protected model's
61
- * connection.
84
+ * connection. With `batchedCollections: true` the adapter also has the optional `DatabaseAdapter`
85
+ * members `findChildrenBatch` and `batchesChildren`; without it, it has neither.
62
86
  */
63
87
  export interface MongoAdapter extends DatabaseAdapter {
64
88
  readonly referentialIntegrity: 'off' | 'transactional';