@simtlix/simfinity-js 3.7.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 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`. 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-js",
3
- "version": "3.7.0",
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.7.0",
33
- "@simtlix/simfinity-mcp": "3.7.0"
32
+ "@simtlix/simfinity-core": "3.8.0",
33
+ "@simtlix/simfinity-mcp": "3.8.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
@@ -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 = generateSchemaDefinition(entryType, true, [...ancestors, gqlType], fieldPath);
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] = [generateSchemaDefinition(itemType, true, [...ancestors, gqlType], fieldPath)];
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
  }
@@ -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
@@ -46,6 +46,8 @@ export type {
46
46
  ScopeParams,
47
47
  TypePermissions,
48
48
  TypeScopes,
49
+ VirtualFieldDeclaration,
50
+ VirtualFieldOptions,
49
51
  } from '@simtlix/simfinity-core';
50
52
  export * from '@simtlix/simfinity-mcp';
51
53
  export { default as mcp } from '@simtlix/simfinity-mcp';
@@ -163,6 +165,13 @@ export function addNoEndpointType(gqltype: GraphQLObjectType): void;
163
165
  * Embedded types that contain each other throw INVALID_MODEL (400, `Embedded cycle at …`) when a
164
166
  * generated model's embedded fields reach them, and always in transactional mode; in default mode a
165
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.
166
175
  * A generated input that would have no fields is left out with a warning, together with the
167
176
  * collection operation, embedded field or add/update/state-action mutation that would take it; a
168
177
  * non-null embedded field whose embedded type has no writable fields throws INVALID_MODEL (400)
@@ -296,6 +305,7 @@ export interface FilterGroupInput {
296
305
  * pipeline. `input.aggregation` filters a field named `aggregation`; the aggregate stage is never
297
306
  * built here. An embedded `.id` path uses the declared `id` member, except for entity types embedded
298
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).
299
309
  */
300
310
  export function buildQuery(
301
311
  input: Record<string, any>,
@@ -303,7 +313,10 @@ export function buildQuery(
303
313
  isCount?: boolean,
304
314
  ): Promise<Array<Record<string, any>>>;
305
315
 
306
- /** Translate a QLFilterGroup into a MongoDB `$match` expression, collecting required lookups. */
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
+ */
307
320
  export function buildFilterGroupMatch(
308
321
  filterGroup: FilterGroupInput,
309
322
  gqltype: GraphQLObjectType,