@simtlix/simfinity-core 3.12.0 → 3.14.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/src/runtime.js CHANGED
@@ -13,14 +13,16 @@ import SimfinityError from './errors/simfinity.error.js';
13
13
  import InternalServerError from './errors/internal-server.error.js';
14
14
  import { ForbiddenError } from './auth/errors.js';
15
15
  import QLOperator from './const/QLOperator.js';
16
+ import QLQuantifier, { QUANTIFIED_LISTS } from './const/QLQuantifier.js';
17
+ import { aggregateJoinPrefixes, assertSortSyntax, frameUnits, validateQuantifiedGroups } from './query-frames.js';
16
18
  import QLValue from './const/QLValue.js';
17
19
  import QLSort from './const/QLSort.js';
18
20
  import {
19
- collectQueryOperators, collectQueryPathEntries, collectQueryPaths, walkQueryPath,
21
+ collectQueryOperators, collectQueryPathEntries, walkQueryPath,
20
22
  } from './query-plan.js';
21
23
  import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
22
24
  import {
23
- assertEmptinessPath, describeEmptiness, emptinessScopePaths, emptinessScopeTargets, isEmptinessOperator,
25
+ assertEmptinessPath, describeEmptiness, emptinessScopeTargets, isEmptinessOperator,
24
26
  } from './query-emptiness.js';
25
27
  import { relationFieldOwners } from './relation-owners.js';
26
28
  import { isThenable } from './auth/thenable.js';
@@ -33,6 +35,12 @@ import {
33
35
  import {
34
36
  fieldMetadataNamesFixed, fieldMetadataTypes, fixFieldMetadataNames, renameFieldMetadataTypes,
35
37
  } from './introspection.js';
38
+ import {
39
+ MAX_RECORD_VERSION, RecordVersion, RecordVersionRef, assertVersionInput, concurrencyDeclared, createVersionFieldConfig,
40
+ isProtectedType, isVersionField, readStoredVersion, recordVersionFieldMisuse, retainedStoredVersion, unadvancedVersionError,
41
+ unexpectedDeleteError, versionConflictError, versionConnectionCollision, versionEmbeddedMisuse, versionExhaustedError,
42
+ versionFieldCollision, versionManagedError,
43
+ } from './record-version.js';
36
44
 
37
45
  // Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
38
46
  // created is application code, such as a masking resolver, recorded once per type so later
@@ -85,6 +93,33 @@ const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER
85
93
  // Generated inputs never write a readOnly field, nor a virtual one, which virtual implies.
86
94
  const isReadOnlyField = (field) => !!field.extensions?.readOnly || isVirtualField(field);
87
95
 
96
+ // Optimistic concurrency (#98). A type is protected once it carries the version field createSchema()
97
+ // injects, which it keeps for good, so code that builds schemas and runs requests asks this instead of
98
+ // reading the live `extensions.concurrency`.
99
+ const isProtected = (gqltype) => isVersionField(gqltype.getFields().version);
100
+ // Whether each registered type was protected when the first createSchema() that contained it returned,
101
+ // for every runtime of this copy of the core code. A failed createSchema() records nothing.
102
+ const protectionStatus = new WeakMap();
103
+
104
+ const invalidWriteFeatures = (message) => {
105
+ throw new SimfinityError(`DatabaseAdapter writeFeatures ${message}`, 'INVALID_WRITE_FEATURES', 500);
106
+ };
107
+
108
+ // Reads an adapter's `writeFeatures` declaration once, into a frozen copy. Undefined or null declares
109
+ // nothing; a plain object may declare `optimisticConcurrency` as a boolean, and its other members are
110
+ // left for later features. Anything else throws INVALID_WRITE_FEATURES (500).
111
+ const resolveWriteFeatures = (writeFeatures) => {
112
+ if (writeFeatures == null) return Object.freeze({ optimisticConcurrency: false });
113
+ if (typeof writeFeatures !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(writeFeatures))) {
114
+ invalidWriteFeatures('must be a plain object');
115
+ }
116
+ const { optimisticConcurrency } = writeFeatures;
117
+ if (optimisticConcurrency !== undefined && typeof optimisticConcurrency !== 'boolean') {
118
+ invalidWriteFeatures('optimisticConcurrency must be a boolean');
119
+ }
120
+ return Object.freeze({ optimisticConcurrency: optimisticConcurrency === true });
121
+ };
122
+
88
123
  // Query scopes restrict reads, so a scope Simfinity cannot run must fail rather than be skipped.
89
124
  const SCOPE_OPERATIONS = ['find', 'get_by_id', 'aggregate'];
90
125
  const invalidScope = (typeName, detail) => new SimfinityError(
@@ -148,7 +183,7 @@ const assertScopeControlArg = (name, value) => {
148
183
  };
149
184
 
150
185
  // Moves a joined type's scope group below the relation path that entered it.
151
- const prefixScopeGroup = (group, prefix) => {
186
+ const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
152
187
  if (!group || typeof group !== 'object' || Array.isArray(group)) {
153
188
  throw new SimfinityError('Expected a scope filter group object', 'INVALID_FILTER_VALUE', 400);
154
189
  }
@@ -160,12 +195,15 @@ const prefixScopeGroup = (group, prefix) => {
160
195
  value: condition?.value,
161
196
  }));
162
197
  }
163
- if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix));
164
- if (group.OR != null) prefixed.OR = [].concat(group.OR).map((item) => prefixScopeGroup(item, prefix));
198
+ if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
199
+ if (group.OR != null) prefixed.OR = [].concat(group.OR).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
200
+ for (const key of QUANTIFIED_LISTS) if (group[key] != null) {
201
+ prefixed[key] = group[key].map((item) => ({ ...copyQuantifiedGroup(item), path: `${prefix}.${item.path}` }));
202
+ }
165
203
  return prefixed;
166
204
  };
167
205
 
168
- const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
206
+ const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
169
207
  const conditions = [];
170
208
  const groups = [];
171
209
  for (const [name, value] of Object.entries(scopeArgs)) {
@@ -174,10 +212,12 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
174
212
  assertScopeControlArg(name, value);
175
213
  continue;
176
214
  }
177
- if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix)));
215
+ if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix, copyQuantifiedGroup)));
178
216
  else if (name === 'OR') {
179
217
  const branches = [].concat(value);
180
- if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix)) });
218
+ if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix, copyQuantifiedGroup)) });
219
+ } else if (value.quantifier != null) {
220
+ groups.push({ [value.quantifier]: [{ path: `${prefix}.${name}`, conditions: (value.terms ?? []).map(({ path, ...term }) => ({ field: path, ...term })) }] });
181
221
  } else if (Object.hasOwn(value, 'terms')) {
182
222
  if (!Array.isArray(value.terms) || !value.terms.length) {
183
223
  throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
@@ -280,6 +320,24 @@ const assertFieldMetadataNamesFree = (roots) => {
280
320
  if (clash) throw reservedTypeNameError(clash);
281
321
  };
282
322
 
323
+ // A schema with protected types contains RecordVersion, and RecordVersionRef whenever a generated input
324
+ // reaches a collection of protected items, so an application type with either name, other than the
325
+ // instances this module exports, is rejected there (#98). Like the field metadata names, they are first
326
+ // checked before anything is built, reading input objects by their own name only, and then in the whole
327
+ // schema when it is constructed.
328
+ const versionTypeClash = (roots, options) => [RecordVersion, RecordVersionRef]
329
+ .map((own) => findSchemaType(roots, own.name, own, options)).find(Boolean) || null;
330
+ // A scalar named RecordVersion with core's own description is almost always core's scalar from a
331
+ // second copy of this package, which protected types cannot work with.
332
+ const reservedVersionTypeNameError = (clash) => new SimfinityError(
333
+ clash instanceof GraphQLScalarType && clash.name === RecordVersion.name && clash.description === RecordVersion.description
334
+ ? 'RecordVersion is reserved for optimistic concurrency, and this RecordVersion comes from another copy of '
335
+ + '@simtlix/simfinity-core: two copies of the package are loaded; import RecordVersion from the facade the '
336
+ + 'application uses'
337
+ : `${clash.name} is reserved for optimistic concurrency; rename the application type`,
338
+ 'RESERVED_TYPE_NAME', 500,
339
+ );
340
+
283
341
  const buildSchemaConfig = (query, mutation) => {
284
342
  const config = { query };
285
343
  if (Object.keys(mutation.getFields()).length > 0) {
@@ -292,12 +350,15 @@ const buildSchemaConfig = (query, mutation) => {
292
350
  // those are renamed SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended
293
351
  // when the schema also has those names, in every schema the process constructs from then on. A
294
352
  // construction that fails puts the previous metadata types back, so only a schema that exists fixes
295
- // the names.
296
- const constructSchema = (query, mutation) => {
353
+ // the names. With `reserveVersionTypeNames`, for a schema with protected types, an application type
354
+ // named RecordVersion or RecordVersionRef fails, even one that only an input field reaches.
355
+ const constructSchema = (query, mutation, reserveVersionTypeNames = false) => {
297
356
  const config = buildSchemaConfig(query, mutation);
298
357
  const roots = Object.values(config);
299
358
  const clash = fieldMetadataClash(roots);
300
359
  if (clash && fieldMetadataNamesFixed()) throw reservedTypeNameError(clash);
360
+ const versionClash = reserveVersionTypeNames && versionTypeClash(roots);
361
+ if (versionClash) throw reservedVersionTypeNameError(versionClash);
301
362
  let names = null;
302
363
  if (clash) {
303
364
  const taken = (name) => findSchemaType(roots, name) !== null;
@@ -431,6 +492,21 @@ const readOwnField = markGenerated((parent, args, context, info) => {
431
492
  return typeof property === 'function' ? property.call(parent, args, context, info) : property;
432
493
  });
433
494
 
495
+ // Puts the version field into a protected type's getFields() map, after the type's own fields, unless
496
+ // the type carries it already: from an earlier schema, another runtime of this core copy that shares
497
+ // the type, or the type it was copied from. The resolver reads only the parent, so it binds the type to
498
+ // no runtime, and a record without a stored version, such as a MongoDB document written before the
499
+ // type was protected, reads as version 1. An invalid stored version is reported under the type being
500
+ // resolved, which a renamed copy shares the resolver with, or under the type it was injected into.
501
+ const injectVersionField = (gqltype) => {
502
+ if (isProtected(gqltype)) return;
503
+ const typeName = gqltype.name;
504
+ gqltype.getFields().version = createVersionFieldConfig(markGenerated((parent, args, context, info) => readStoredVersion(
505
+ retainedStoredVersion(parent) ?? ownValue(parent, 'version'),
506
+ { typeName: info?.parentType?.name ?? typeName, id: ownValue(parent, '_id') ?? ownValue(parent, 'id') },
507
+ )));
508
+ };
509
+
434
510
  const cloneInput = (value, type) => {
435
511
  if (value === null || value === undefined || !type) return value;
436
512
  if (type instanceof GraphQLNonNull) return cloneInput(value, type.ofType);
@@ -476,9 +552,12 @@ export const createRuntime = (adapter) => {
476
552
  // IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
477
553
  // conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
478
554
  const {
479
- operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness,
555
+ operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers,
480
556
  } = resolveQueryFeatures(adapter.queryFeatures);
481
557
  const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
558
+ // The write features the adapter implements, read once: with optimisticConcurrency, schemas may
559
+ // contain protected types (#98).
560
+ const writeFeatures = resolveWriteFeatures(adapter.writeFeatures);
482
561
 
483
562
  const stateValue = adapter.stateValue || ((state) => state.name);
484
563
  const runtimeIdentity = {};
@@ -494,6 +573,9 @@ export const createRuntime = (adapter) => {
494
573
  const selfCollectionNameHolders = new Map();
495
574
  const qualifiedSelfCollections = new Map();
496
575
  const registeredMutations = {};
576
+ // Models createModel() returned for this runtime, as opposed to models supplied to connect(). A type
577
+ // with one was built for storage without a version field, even by a createSchema() that failed later.
578
+ const generatedModels = new WeakSet();
497
579
  const registeredQueries = {};
498
580
  const registeredViews = {};
499
581
  const middlewares = [];
@@ -556,9 +638,9 @@ export const createRuntime = (adapter) => {
556
638
  }
557
639
  };
558
640
 
559
- // Object and interface types a schema of this runtime can reach from its registered types and
560
- // custom operation results, following the same references as GraphQLSchema.
561
- const collectSchemaOutputTypes = () => {
641
+ // Object and interface types reachable from these roots, following the output references of
642
+ // GraphQLSchema: fields, interfaces and union members. Input objects cannot hold either.
643
+ const collectOutputTypes = (roots) => {
562
644
  const visited = new Set();
563
645
  const outputTypes = [];
564
646
  const visit = (type) => {
@@ -574,14 +656,20 @@ export const createRuntime = (adapter) => {
574
656
  named.getInterfaces().forEach(visit);
575
657
  for (const field of Object.values(named.getFields())) visit(field.type);
576
658
  };
659
+ roots.forEach(visit);
660
+ return outputTypes;
661
+ };
662
+
663
+ // Object and interface types a schema of this runtime can reach from its registered types and
664
+ // custom operation results, following the same references as GraphQLSchema.
665
+ const collectSchemaOutputTypes = () => {
577
666
  const registeredTypes = Object.values(typesDict.types).map(({ gqltype }) => gqltype);
578
667
  const customOperations = [
579
668
  ...Object.values(registeredMutations),
580
669
  ...Object.values(registeredQueries),
581
670
  ];
582
671
  const customOutputTypes = customOperations.map(({ outputModel }) => outputModel);
583
- [...registeredTypes, ...customOutputTypes].forEach(visit);
584
- return outputTypes;
672
+ return collectOutputTypes([...registeredTypes, ...customOutputTypes]);
585
673
  };
586
674
 
587
675
  const hasUnresolvedRelation = (gqltype) => Object.values(gqltype.getFields()).some(
@@ -616,6 +704,7 @@ const QLTypeFilterExpression = new GraphQLInputObjectType({
616
704
  name: 'QLTypeFilterExpression',
617
705
  fields: () => ({
618
706
  terms: { type: new GraphQLList(QLTypeFilter) },
707
+ quantifier: { type: QLQuantifier },
619
708
  }),
620
709
  });
621
710
 
@@ -629,9 +718,25 @@ const QLFilterCondition = new GraphQLInputObjectType({
629
718
  }),
630
719
  });
631
720
 
721
+ const QLQuantifiedGroup = new GraphQLInputObjectType({
722
+ name: 'QLQuantifiedGroup',
723
+ fields: () => ({
724
+ path: { type: new GraphQLNonNull(GraphQLString) },
725
+ AND: { type: new GraphQLList(QLFilterGroup) },
726
+ OR: { type: new GraphQLList(QLFilterGroup) },
727
+ ANY: { type: new GraphQLList(QLQuantifiedGroup) },
728
+ ALL: { type: new GraphQLList(QLQuantifiedGroup) },
729
+ NONE: { type: new GraphQLList(QLQuantifiedGroup) },
730
+ conditions: { type: new GraphQLList(QLFilterCondition) },
731
+ }),
732
+ });
733
+
632
734
  const QLFilterGroup = new GraphQLInputObjectType({
633
735
  name: 'QLFilterGroup',
634
736
  fields: () => ({
737
+ ANY: { type: new GraphQLList(QLQuantifiedGroup) },
738
+ ALL: { type: new GraphQLList(QLQuantifiedGroup) },
739
+ NONE: { type: new GraphQLList(QLQuantifiedGroup) },
635
740
  AND: { type: new GraphQLList(QLFilterGroup) },
636
741
  OR: { type: new GraphQLList(QLFilterGroup) },
637
742
  conditions: { type: new GraphQLList(QLFilterCondition) },
@@ -814,8 +919,10 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
814
919
  + `\`${operation}\` operation in generated inputs, ${omittedOperation[operation]()}`);
815
920
  }
816
921
  }
817
- // Null IDs are skipped, so `deleted` accepts them whatever the collection's item nullability.
818
- fields.deleted = { type: new GraphQLList(GraphQLID) };
922
+ // Null entries are skipped, so `deleted` accepts them whatever the collection's item nullability.
923
+ // A protected item is deleted at the version the client read, whether or not the parent is
924
+ // protected (#98).
925
+ fields.deleted = { type: new GraphQLList(isProtected(itemType) ? RecordVersionRef : GraphQLID) };
819
926
  return fields;
820
927
  },
821
928
  });
@@ -1050,6 +1157,14 @@ const buildInputType = (gqltype, reachable) => {
1050
1157
  description: fieldEntry.description,
1051
1158
  };
1052
1159
  }
1160
+ // The version field is readOnly, so only this adds it, last and required, to a protected type's
1161
+ // update input, which updates, state actions and nested `updated` entries take (#98). Create inputs,
1162
+ // and the `added` items built from them, never get it. An update input that would hold only the
1163
+ // version, with no writable id or other field, is left without fields as before (#161), so the type
1164
+ // has no update mutation, state actions or nested `updated` entries, and keeps its delete mutation.
1165
+ if (isProtected(gqltype) && Object.keys(fieldsArgForUpdate).length > 0) {
1166
+ fieldsArgForUpdate.version = { type: new GraphQLNonNull(RecordVersion), description: argTypes.version.description };
1167
+ }
1053
1168
 
1054
1169
  const inputTypeForAdd = new GraphQLInputObjectType({
1055
1170
  name: `${gqltype.name}Input`,
@@ -1222,8 +1337,9 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
1222
1337
  const collectionFields = {};
1223
1338
 
1224
1339
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
1225
- // A virtual field stores nothing, whatever middleware or saveObject() passes for it.
1226
- if (isVirtualField(fieldEntry)) continue;
1340
+ // A virtual field stores nothing, whatever middleware or saveObject() passes for it, and Simfinity
1341
+ // writes the version field of a protected type itself (#98).
1342
+ if (isVirtualField(fieldEntry) || isVersionField(fieldEntry)) continue;
1227
1343
  const fieldValue = ownValue(args, fieldEntryName);
1228
1344
  if (fieldEntry.extensions && fieldEntry.extensions.validations
1229
1345
  && fieldEntry.extensions.validations[operation]) {
@@ -1339,10 +1455,58 @@ const iterateOnCollectionFields = async (materializedModel, gqltype, objectId, s
1339
1455
  const readByIdIsInert = (Model) => typeof adapter.readByIdIsInert === 'function'
1340
1456
  && adapter.readByIdIsInert(Model) === true;
1341
1457
 
1458
+ // Optimistic concurrency (#98). Every update, state action, delete and nested entry of a protected
1459
+ // type first reads its record whole, plain and locked: the pre-read, which also serves the embedded
1460
+ // merge, the from-state check and onDelete. Its version is normalized before any hook sees it, so a
1461
+ // MongoDB document stored before the type was protected shows 1, as on PostgreSQL.
1462
+ const normalizeProtectedVersion = (record, gqltype, id) => {
1463
+ record.version = readStoredVersion(record.version,
1464
+ { typeName: gqltype.name, id: ownValue(record, '_id') ?? ownValue(record, 'id') ?? id });
1465
+ return record.version;
1466
+ };
1467
+
1468
+ const readProtectedRecord = async (Model, gqltype, id, session) => {
1469
+ const record = await adapter.getById(Model, id, session, { plain: true, lock: true });
1470
+ if (record) normalizeProtectedVersion(record, gqltype, id);
1471
+ return record;
1472
+ };
1473
+
1474
+ // Whether a stale or exhausted version that the pre-read found is reported at once, before the
1475
+ // record's validators, hooks and action run (decision 9): to every caller of a type without a
1476
+ // get_by_id scope, and to a caller who can read the record through that scope. The record is read as
1477
+ // a by-ID read does, in the operation's session and by its stored key, not the client's spelling of
1478
+ // the ID. A scope function that throws counts as hiding the record; errors of the gates and of
1479
+ // adapter.find propagate.
1480
+ const reportEarlyConflict = async (type, record, context, session) => {
1481
+ if (!getScope(type.gqltype, 'get_by_id')) return true;
1482
+ const key = String(record._id ?? record.id);
1483
+ const args = { id: { operator: 'EQ', value: key } };
1484
+ try {
1485
+ return await findVisibleById(type, args, session, context, key) !== null;
1486
+ } catch (error) {
1487
+ if (failedByIdScopes.has(args)) return false;
1488
+ throw error;
1489
+ }
1490
+ };
1491
+
1492
+ // Outcome 3 of the pre-read: the stored version is not the expected one, or an update or a state
1493
+ // action (`advances`) would take it past MAX_RECORD_VERSION; a delete does not advance it. When the
1494
+ // conflict is not reported early, the operation continues exactly as with a matching version, and the
1495
+ // conditional write reports it.
1496
+ const assertRecordVersion = async (gqltype, id, record, expectedVersion, advances, context, session) => {
1497
+ let error = null;
1498
+ if (record.version !== expectedVersion) error = versionConflictError(gqltype.name, id);
1499
+ else if (advances && expectedVersion === MAX_RECORD_VERSION) error = versionExhaustedError(gqltype.name, id);
1500
+ if (error && await reportEarlyConflict(typesDict.types[gqltype.name], record, context, session)) throw error;
1501
+ };
1502
+
1342
1503
  // The record is read for the onDelete hook; adapter.delete casts the ID, deletes and returns the
1343
1504
  // record itself. Without a hook, the read is skipped when it is inert. A nested delete passes the
1344
- // child it has already read as `current`.
1345
- const onDelete = async (Model, controller, id, session, context, current) => {
1505
+ // child it has already read as `current`, and a protected delete its pre-read record, null when it is
1506
+ // missing, with `precondition`: the type and the version the record must still have. The adapter then
1507
+ // deletes it only at that version, and a record it deleted at another one, which an adapter wrapper
1508
+ // that drops the options would delete, fails with DATABASE_ERROR and rolls back with the transaction.
1509
+ const onDelete = async (Model, controller, id, session, context, current, precondition = null) => {
1346
1510
  const hasHook = !!(controller && controller.onDelete);
1347
1511
  let currentObject = current;
1348
1512
  if (currentObject === undefined && (hasHook || !readByIdIsInert(Model))) {
@@ -1353,7 +1517,13 @@ const onDelete = async (Model, controller, id, session, context, current) => {
1353
1517
  await controller.onDelete(currentObject, session, context);
1354
1518
  }
1355
1519
 
1356
- return adapter.delete(Model, id, session);
1520
+ if (!precondition) return adapter.delete(Model, id, session);
1521
+ const { gqltype, expectedVersion } = precondition;
1522
+ const deleted = await adapter.delete(Model, id, session, { expectedVersion });
1523
+ if (deleted && readStoredVersion(deleted.version, { typeName: gqltype.name, id }) !== expectedVersion) {
1524
+ throw unexpectedDeleteError(gqltype.name, id);
1525
+ }
1526
+ return deleted;
1357
1527
  };
1358
1528
 
1359
1529
  const getEmbeddedFieldNames = (gqltype) => {
@@ -1428,7 +1598,7 @@ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
1428
1598
  // onUpdating, so that a malformed ID fails before the hook, and before nested collection items, so
1429
1599
  // that the SQL adapter locks the parent row before child writes; otherwise it skips the read only
1430
1600
  // when it is inert. A state action or a nested update has already read and checked the record
1431
- // (`recordChecked`).
1601
+ // (`recordChecked`). A protected type never reads here: its pre-read record serves the merge (#98).
1432
1602
  const readsStoredEmbedded = (Model, controller, materializedModel, embeddedFieldNames, recordChecked) => {
1433
1603
  const usesStored = embeddedFieldNames.some((name) => {
1434
1604
  const value = ownValue(materializedModel.modelArgs, name);
@@ -1442,17 +1612,29 @@ const readsStoredEmbedded = (Model, controller, materializedModel, embeddedField
1442
1612
  || !readByIdIsInert(Model);
1443
1613
  };
1444
1614
 
1615
+ // Whether an update sets the version field, at the top level or inside an operator such as $set or
1616
+ // $unset. The adapter writes the version of a protected type (#98).
1617
+ const setsVersion = (update) => [update, ...Object.entries(update)
1618
+ .filter(([key]) => key.startsWith('$')).map(([, value]) => value)]
1619
+ .some((target) => target !== null && typeof target === 'object' && Object.hasOwn(target, 'version'));
1620
+
1621
+ // The update of a protected type (#98) takes from its caller the version captured before the
1622
+ // pre-read, `expectedVersion`, the immutable observed version, and the pre-read record (null when
1623
+ // missing). It merges embedded values from that record and writes only at the expected version,
1624
+ // which the adapter advances; onUpdating
1625
+ // cannot set the version, and a result that did not advance it fails with DATABASE_ERROR.
1445
1626
  const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context,
1446
- recordChecked = false) => {
1627
+ recordChecked = false, { expectedVersion, observedVersion, record } = {}) => {
1628
+ const versioned = isProtected(gqltype);
1447
1629
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
1448
1630
  const objectId = args.id;
1449
1631
  const argTypes = gqltype.getFields();
1450
1632
  const embeddedFieldNames = getEmbeddedFieldNames(gqltype);
1451
1633
 
1452
1634
  if (embeddedFieldNames.length > 0
1453
- && readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked)) {
1635
+ && (versioned || readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked))) {
1454
1636
  const projection = Object.fromEntries(embeddedFieldNames.map((name) => [name, 1]));
1455
- const currentObject = await adapter.getById(Model, objectId, session, {
1637
+ const currentObject = versioned ? record : await adapter.getById(Model, objectId, session, {
1456
1638
  projection,
1457
1639
  plain: true,
1458
1640
  lock: true,
@@ -1477,7 +1659,8 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1477
1659
  }
1478
1660
 
1479
1661
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
1480
- if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)) {
1662
+ if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)
1663
+ && !isVersionField(fieldEntry)) {
1481
1664
  materializedModel.modelArgs.$unset = {
1482
1665
  ...materializedModel.modelArgs.$unset,
1483
1666
  [getFieldStorageName(fieldEntryName, fieldEntry)]: '',
@@ -1492,11 +1675,24 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1492
1675
 
1493
1676
  if (controller && controller.onUpdating) {
1494
1677
  await controller.onUpdating(objectId, update, session, context);
1678
+ if (versioned && setsVersion(update)) throw versionManagedError(gqltype.name, 'onUpdating');
1495
1679
  }
1496
1680
 
1497
1681
  if (linkToParent) linkToParent(update);
1498
- const result = await adapter.update(Model, objectId, update, session);
1682
+ const result = versioned
1683
+ ? await adapter.update(Model, objectId, update, session, { expectedVersion })
1684
+ : await adapter.update(Model, objectId, update, session);
1499
1685
  if (!result) throw new SimfinityError(`${gqltype.name} ${objectId} is not valid`, 'NOT_VALID_ID', 404);
1686
+ // Diagnose a result that did not advance beyond the pre-read, including a dropped precondition
1687
+ // when the caller sent one revision less. This cannot prove arbitrary wrappers honored the atomic
1688
+ // condition: they must still forward it. The observation is captured before application code runs.
1689
+ if (versioned) {
1690
+ const returnedVersion = readStoredVersion(result.version, { typeName: gqltype.name, id: objectId });
1691
+ if (returnedVersion !== expectedVersion + 1
1692
+ || (observedVersion !== undefined && returnedVersion <= observedVersion)) {
1693
+ throw unadvancedVersionError(gqltype.name, objectId);
1694
+ }
1695
+ }
1500
1696
 
1501
1697
  if (materializedModel.collectionFields) {
1502
1698
  // Children link to, and are owned through, the parent's stored key, as when a new parent is
@@ -1513,18 +1709,27 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1513
1709
  return result;
1514
1710
  };
1515
1711
 
1712
+ // A protected type's version is captured before the action can change `args`, and checked on the
1713
+ // plain pre-read record before the from-state check, which reads `state` from that record (#98).
1516
1714
  const onStateChanged = async (Model, gqltype, controller, args, session, actionField, context) => {
1517
- const storedModel = await adapter.getById(Model, args.id, session, { lock: true });
1715
+ const versioned = isProtected(gqltype);
1716
+ const expectedVersion = versioned ? assertVersionInput(args.version) : undefined;
1717
+ const storedModel = versioned
1718
+ ? await readProtectedRecord(Model, gqltype, args.id, session)
1719
+ : await adapter.getById(Model, args.id, session, { lock: true });
1720
+ const observedVersion = versioned ? storedModel?.version : undefined;
1518
1721
  if (!storedModel) {
1519
1722
  throw new SimfinityError(`${gqltype.name} ${args.id} is not valid`, 'NOT_VALID_ID', 404);
1520
1723
  }
1724
+ if (versioned) await assertRecordVersion(gqltype, args.id, storedModel, expectedVersion, true, context, session);
1521
1725
  if (storedModel.state === stateValue(actionField.from)) {
1522
1726
  if (actionField.action) {
1523
1727
  await actionField.action(args, session);
1524
1728
  }
1525
1729
 
1526
1730
  args.state = stateValue(actionField.to);
1527
- let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context, true);
1731
+ let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context, true,
1732
+ versioned ? { expectedVersion, observedVersion, record: storedModel } : undefined);
1528
1733
  result = adapter.toObject(result);
1529
1734
  result.state = actionField.to.value;
1530
1735
  return result;
@@ -1539,11 +1744,15 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
1539
1744
  typesDict.types[gqltype.name].stateMachine.initialState,
1540
1745
  );
1541
1746
  }
1747
+ // A protected record starts at version 1, whatever saveObject() or middleware supplied (#98).
1748
+ const versioned = isProtected(gqltype);
1749
+ if (versioned) materializedModel.modelArgs.version = 1;
1542
1750
 
1543
1751
  const newObject = adapter.newRecord(Model, materializedModel.modelArgs, session);
1544
1752
 
1545
1753
  if (controller && controller.onSaving) {
1546
1754
  await controller.onSaving(newObject, args, session, context);
1755
+ if (versioned && newObject.version !== 1) throw versionManagedError(gqltype.name, 'onSaving');
1547
1756
  }
1548
1757
 
1549
1758
  if (linkToParent) linkToParent(newObject);
@@ -1635,12 +1844,30 @@ const assertCollectionWrites = (gqltype, input) => {
1635
1844
  }
1636
1845
  };
1637
1846
 
1847
+ // The capture and the pre-read of a protected top-level update or delete (#98): the version the
1848
+ // client sent is checked before anything is read, then against the pre-read record. A missing record
1849
+ // is not reported here; the update fails at its write with NOT_VALID_ID and the delete returns null,
1850
+ // as for an unprotected type.
1851
+ const preReadTopLevel = async (Model, gqltype, { id, version }, advances, context, session) => {
1852
+ const expectedVersion = assertVersionInput(version);
1853
+ const record = await readProtectedRecord(Model, gqltype, id, session);
1854
+ const observedVersion = record?.version;
1855
+ if (record) await assertRecordVersion(gqltype, id, record, expectedVersion, advances, context, session);
1856
+ return { expectedVersion, observedVersion, record };
1857
+ };
1858
+
1859
+ // A delete passes the ID, or { id, version } for a protected type, which each attempt copies; every
1860
+ // other operation passes its input, which attemptInputs copies. Either way, a retry checks the version
1861
+ // the request sent.
1638
1862
  const executeOperation = (Model, gqltype, controller, args, operation, actionField,
1639
1863
  session, context, inputType) => {
1640
1864
  const limit = getMaxNestedOperations();
1641
1865
  if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
1642
1866
  if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
1643
- const nextInput = operation === operations.DELETE ? () => args : attemptInputs(args, inputType);
1867
+ const versioned = isProtected(gqltype);
1868
+ let nextInput = () => args;
1869
+ if (operation !== operations.DELETE) nextInput = attemptInputs(args, inputType);
1870
+ else if (versioned) nextInput = () => ({ ...args });
1644
1871
  return adapter.withTransaction(
1645
1872
  session,
1646
1873
  async (mySession) => {
@@ -1648,10 +1875,17 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
1648
1875
  switch (operation) {
1649
1876
  case operations.SAVE:
1650
1877
  return onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
1651
- case operations.UPDATE:
1652
- return onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context);
1653
- case operations.DELETE:
1654
- return onDelete(Model, controller, attemptArgs, mySession, context);
1878
+ case operations.UPDATE: {
1879
+ const precondition = versioned
1880
+ ? await preReadTopLevel(Model, gqltype, attemptArgs, true, context, mySession) : undefined;
1881
+ return onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context, false, precondition);
1882
+ }
1883
+ case operations.DELETE: {
1884
+ if (!versioned) return onDelete(Model, controller, attemptArgs, mySession, context);
1885
+ const { expectedVersion, record } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
1886
+ mySession);
1887
+ return onDelete(Model, controller, attemptArgs.id, mySession, context, record, { gqltype, expectedVersion });
1888
+ }
1655
1889
  case operations.STATE_CHANGED:
1656
1890
  return onStateChanged(Model, gqltype, controller,
1657
1891
  attemptArgs, mySession, actionField, context);
@@ -1684,14 +1918,28 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1684
1918
  if (item.$set) delete item.$set[connection.storageFieldName];
1685
1919
  };
1686
1920
 
1921
+ // A protected child is updated or deleted at the version its entry carries (#98). A deleted entry is
1922
+ // { id, version } for item middleware; one that is not an object, such as a string ID, has no version.
1923
+ const versioned = isProtected(collectionGQLType) && operationType !== operations.SAVE;
1687
1924
  for (const element of collectionFieldsList) {
1688
1925
  if (element === null) continue;
1689
- const args = operationType === operations.DELETE ? { id: element } : { input: element };
1926
+ let args = { input: element };
1927
+ if (operationType === operations.DELETE) {
1928
+ args = versioned ? { id: element?.id, version: element?.version } : { id: element };
1929
+ }
1690
1930
  await executeMiddleware({ type, args, operation: operationType, context });
1691
1931
 
1932
+ // Captured after the item's middleware, before the child is read.
1933
+ let expectedVersion;
1934
+ if (versioned) {
1935
+ expectedVersion = assertVersionInput(operationType === operations.DELETE ? args.version : args.input.version);
1936
+ }
1937
+
1692
1938
  // The ownership check reads the child (the SQL adapter locks its row). onDelete receives this
1693
- // record, and an update reads the child again only to merge embedded values.
1939
+ // record, and an update reads the child again only to merge embedded values, unless the child is
1940
+ // protected: then this is its pre-read, whose version is checked after ownership.
1694
1941
  let child;
1942
+ let observedVersion;
1695
1943
  if (operationType === operations.UPDATE || operationType === operations.DELETE) {
1696
1944
  const id = operationType === operations.DELETE ? args.id : args.input.id;
1697
1945
  child = await adapter.getById(type.model, id, session, { plain: true, lock: true });
@@ -1701,6 +1949,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1701
1949
  if (String(child[connection.storageFieldName]) !== String(parentKey)) {
1702
1950
  throw new ForbiddenError('Child does not belong to this parent');
1703
1951
  }
1952
+ if (versioned) {
1953
+ observedVersion = normalizeProtectedVersion(child, collectionGQLType, id);
1954
+ await assertRecordVersion(collectionGQLType, id, child, expectedVersion, operationType === operations.UPDATE,
1955
+ context, session);
1956
+ }
1704
1957
  }
1705
1958
 
1706
1959
  switch (operationType) {
@@ -1710,10 +1963,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1710
1963
  break;
1711
1964
  case operations.UPDATE:
1712
1965
  await onUpdateSubject(type.model, collectionGQLType, type.controller,
1713
- args.input, session, linkToParent, context, true);
1966
+ args.input, session, linkToParent, context, true, versioned ? { expectedVersion, observedVersion, record: child } : undefined);
1714
1967
  break;
1715
1968
  case operations.DELETE:
1716
- await onDelete(type.model, type.controller, args.id, session, context, child);
1969
+ await onDelete(type.model, type.controller, args.id, session, context, child,
1970
+ versioned ? { gqltype: collectionGQLType, expectedVersion } : null);
1717
1971
  }
1718
1972
  }
1719
1973
  };
@@ -1780,8 +2034,10 @@ const copyFilterGroup = (group) => {
1780
2034
  if (Array.isArray(group.conditions)) copy.conditions = group.conditions.map(copyFilterCondition);
1781
2035
  if (Array.isArray(group.AND)) copy.AND = group.AND.map(copyFilterGroup);
1782
2036
  if (Array.isArray(group.OR)) copy.OR = group.OR.map(copyFilterGroup);
2037
+ for (const key of QUANTIFIED_LISTS) if (Array.isArray(group[key])) copy[key] = group[key].map(copyQuantifiedGroup);
1783
2038
  return copy;
1784
2039
  };
2040
+ const copyQuantifiedGroup = (group) => copyFilterGroup(group);
1785
2041
  const copyFieldFilter = (filter) => {
1786
2042
  const copy = copyFilterCondition(filter);
1787
2043
  if (isFilterObject(filter) && Array.isArray(filter.terms)) copy.terms = filter.terms.map(copyFilterCondition);
@@ -1818,12 +2074,19 @@ const copyClientFilters = (args, operation) => {
1818
2074
  // non-queryable field is not rejected; a virtual one still is.
1819
2075
  const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true } = {}) => {
1820
2076
  copyClientFilters(args, operation);
2077
+ assertSortSyntax(args.sort);
1821
2078
  const joinedScopes = new Map();
2079
+ const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
1822
2080
  const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
1823
2081
  for (const {
1824
- segments, group, member, operator,
2082
+ segments, group, member, operator, use, frames, quantifier,
1825
2083
  } of entries) {
1826
- const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator));
2084
+ const filter = use === 'filter';
2085
+ const relations = filter && supportsQuantifiers ? pathRelations(gqltype, segments) : [];
2086
+ const cut = relations.find((relation) => relation.collection && !J.has(relation.path));
2087
+ const anchored = cut && relations.some((relation) => relation.collection && J.has(relation.path));
2088
+ const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
2089
+ && !(filter && (frames.length || quantifier || (cut && !anchored)));
1827
2090
  walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
1828
2091
  const path = segments.slice(0, index + 1).join('.');
1829
2092
  if (isVirtualField(field)) {
@@ -1833,8 +2096,11 @@ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true }
1833
2096
  if (checkQueryable && (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName)))) {
1834
2097
  throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
1835
2098
  }
1836
- if (!joins) return;
1837
2099
  const relation = field.extensions?.relation;
2100
+ if (use === 'sort' && operation !== 'aggregate' && relation && !relation.embedded && getListShape(field.type)) {
2101
+ throw new SimfinityError(`Sort path ${segments.join('.')} crosses the collection ${type.name}.${fieldName}; a record has no single value there`, 'INVALID_FILTER_PATH', 400);
2102
+ }
2103
+ if (!joins || (cut && index >= cut.index)) return;
1838
2104
  if (!relation || relation.embedded) return;
1839
2105
  const target = typesDict.types[unwrapListAndNonNull(field.type).name];
1840
2106
  if (!getScope(target?.gqltype, 'find')) return;
@@ -1916,31 +2182,43 @@ const assertEmptinessForm = (gqltype, {
1916
2182
  return true;
1917
2183
  };
1918
2184
 
1919
- // Rejects a QLOperator value that the adapter does not declare in queryFeatures, with
1920
- // UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile,
1921
- // and applies to every IS_EMPTY or IS_NOT_EMPTY condition the order of checks of #108 part 2a,
1922
- // whatever the adapter declares, rule 6 only when it does not declare reference emptiness. Runs on
1923
- // the client's arguments before middleware and on the final arguments before the adapter call,
1924
- // which covers what middleware, scopes and joined scopes added, so that an emptiness condition gets
1925
- // the code that a direct createQueryPlan call gives, whoever wrote it and whatever the caller may
1926
- // read. Operators that QLOperator does not list are left to the adapter, which reports them as before.
1927
- // Returns every condition as `{ entry, shape }`: its collectQueryOperators entry, and the shape of an
1928
- // emptiness condition that the backends read as one, or null.
2185
+ // Every runtime gate validates the received language, before rewriting or filling any markers.
2186
+ // Legacy adapters keep the detailed 2b diagnostics; quantified adapters use the shared validator's
2187
+ // frame-relative checks first. The separate operator inspection includes ignored beside-terms
2188
+ // carriers, which do not become predicates but must not use known undeclared capabilities.
2189
+ const queryFrameContext = (gqltype, args, operation) => ({
2190
+ gqltype, operation, quantifiers: supportsQuantifiers, referenceEmptiness: supportsReferenceEmptiness,
2191
+ operators: supportedOperators,
2192
+ J: operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set(),
2193
+ scopeMarkers: 'pending',
2194
+ });
2195
+
2196
+ const entryType = (gqltype, entry) => {
2197
+ let type = gqltype;
2198
+ const prefix = entry.segments.slice(0, entry.segments.length - entry.relative.length);
2199
+ if (prefix.length) walkQueryPath(gqltype, prefix, ({ field }) => { type = getNamedType(field.type); });
2200
+ return type;
2201
+ };
2202
+
1929
2203
  const assertQueryConditions = (gqltype, args, operation) => {
1930
- const checked = [];
2204
+ if (supportsQuantifiers) validateQuantifiedGroups(args, queryFrameContext(gqltype, args, operation));
2205
+ const conditions = [];
1931
2206
  for (const entry of collectQueryOperators(args, operation)) {
1932
2207
  const { operator, path } = entry;
2208
+ const type = entryType(gqltype, entry);
1933
2209
  let shape = null;
1934
2210
  if (isEmptinessOperator(operator)) {
1935
- if (assertEmptinessForm(gqltype, entry)) {
1936
- shape = describeEmptiness(gqltype, entry.segments, {
1937
- operator, value: entry.value, path, referenceEmptiness: supportsReferenceEmptiness,
2211
+ // The validator owns quantified term forms; old top-frame forms retain their diagnostics.
2212
+ if (entry.frames.length || assertEmptinessForm(gqltype, entry)) {
2213
+ if (entry.relative.length) shape = describeEmptiness(type, entry.relative, {
2214
+ operator, value: entry.value, path: entry.relative.join('.'), referenceEmptiness: supportsReferenceEmptiness,
1938
2215
  });
1939
2216
  }
1940
2217
  } else if (!supportsEveryOperator && isQueryOperator(operator)) assertDeclaredOperator(gqltype, operator, path);
1941
- checked.push({ entry, shape });
2218
+ conditions.push({ entry, shape, type });
1942
2219
  }
1943
- return checked;
2220
+ if (!supportsQuantifiers) validateQuantifiedGroups(args, queryFrameContext(gqltype, args, operation));
2221
+ return { conditions, gqltype, args, operation };
1944
2222
  };
1945
2223
 
1946
2224
  // Decision 3 of #108 part 2a: a target find scope with an emptiness condition is not applied below a
@@ -1957,9 +2235,8 @@ const assertQueryConditions = (gqltype, args, operation) => {
1957
2235
  // the prefixed conditions are applied as trusted ones, which read stored data.
1958
2236
  const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target) => {
1959
2237
  const { gqltype } = target;
1960
- const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator }) => isEmptinessOperator(operator));
2238
+ const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator, frames }) => !frames.length && isEmptinessOperator(operator));
1961
2239
  if (!entries.length) return;
1962
- assertStoredQueryPaths(gqltype, scopeArgs, 'find');
1963
2240
  let throughList = false;
1964
2241
  walkQueryPath(rootType, prefix.split('.'), ({ field }) => { throughList ||= !!getListShape(field.type); });
1965
2242
  // Rule 6, here or on the target's own endpoint, is the rejection thrown below.
@@ -1987,20 +2264,28 @@ const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target
1987
2264
  + `use EQ null in the ${gqltype.name} find scope`, 'UNSUPPORTED_QUERY', 400);
1988
2265
  };
1989
2266
 
1990
- // A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
1991
- // reach a non-embedded relation inside an embedded list, which is joined once per list item.
1992
- const assertJoinedScopePaths = (groups, prefix, target) => {
2267
+ // Declared collection frames preserve cardinality. A legacy adapter, or a prefix through an
2268
+ // embedded list, still cannot lift collection scopes; vector reference scopes remain forbidden.
2269
+ const assertJoinedScopePaths = (rootType, groups, prefix, target) => {
2270
+ let embeddedPrefix = false;
2271
+ walkQueryPath(rootType, prefix.split('.'), ({ field }) => {
2272
+ embeddedPrefix ||= !!(field.extensions?.relation?.embedded && getListShape(field.type));
2273
+ });
1993
2274
  const depth = prefix.split('.').length;
1994
- for (const segments of collectQueryPaths({ AND: groups })) {
2275
+ for (const { segments, quantifier } of collectQueryPathEntries({ AND: groups })) {
1995
2276
  let inList = false;
1996
- walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field }) => {
2277
+ walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field, index }) => {
1997
2278
  const relation = field.extensions?.relation;
1998
2279
  const list = !!getListShape(field.type);
1999
- if (relation && !relation.embedded && (list || inList)) {
2280
+ if (relation && !relation.embedded && (inList || (list && (!supportsQuantifiers || embeddedPrefix)))) {
2000
2281
  throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope filters through `
2001
2282
  + `${list ? 'the collection' : 'the relation'} ${type.name}.${fieldName}${list ? '' : ' inside a list'}`);
2002
2283
  }
2003
- inList ||= list;
2284
+ if (relation && !relation.embedded && quantifier && embeddedPrefix && index === segments.length - depth - 1) {
2285
+ throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope tests `
2286
+ + `the quantified reference ${type.name}.${fieldName} under an embedded-list prefix`);
2287
+ }
2288
+ inList = relation && !relation.embedded ? false : inList || list;
2004
2289
  });
2005
2290
  }
2006
2291
  };
@@ -2014,7 +2299,7 @@ const appendAndGroups = (owner, groups) => {
2014
2299
 
2015
2300
  // The deepest AND/OR group nesting the backends accept; groups listed in args.AND/OR are at depth 0.
2016
2301
  const MAX_FILTER_GROUP_DEPTH = 5;
2017
- const nestedGroups = (group) => [...[].concat(group.AND ?? []), ...[].concat(group.OR ?? [])];
2302
+ const nestedGroups = (group) => ['AND', 'OR', ...QUANTIFIED_LISTS].flatMap((key) => [].concat(group[key] ?? []));
2018
2303
  const groupDepth = (group) => Math.max(0, ...nestedGroups(group)
2019
2304
  .filter((item) => item && typeof item === 'object').map((item) => groupDepth(item) + 1));
2020
2305
 
@@ -2041,10 +2326,13 @@ const applyJoinedScopes = async (joinedScopes, params) => {
2041
2326
  }] of joinedScopes) {
2042
2327
  const scopeArgs = {};
2043
2328
  await executeScope({ type: target, args: scopeArgs, operation: 'find', context: params.context });
2044
- const groups = prefixScopeArgs(scopeArgs, prefix, target.gqltype.name);
2329
+ const kept = scopeFilterArgs(scopeArgs);
2330
+ assertStoredQueryPaths(target.gqltype, kept, 'find');
2331
+ assertQueryConditions(target.gqltype, kept, 'find');
2332
+ const groups = prefixScopeArgs(kept, prefix, target.gqltype.name, copyQuantifiedGroup);
2045
2333
  if (!groups.length) continue;
2046
2334
  if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
2047
- assertJoinedScopePaths(groups, prefix, target);
2335
+ assertJoinedScopePaths(params.type.gqltype, groups, prefix, target);
2048
2336
  assertJoinedScopeConditions(params.type.gqltype, scopeArgs, groups, prefix, target);
2049
2337
  queryGroups ??= collectFilterGroups(params.args);
2050
2338
  const addedDepth = 1 + Math.max(...groups.map(groupDepth));
@@ -2054,60 +2342,93 @@ const applyJoinedScopes = async (joinedScopes, params) => {
2054
2342
  }
2055
2343
  };
2056
2344
 
2057
- // Client scope markers (#108 part 2b). When the adapter compiles reference emptiness, each IS_EMPTY or
2058
- // IS_NOT_EMPTY term or group condition that a client sends and that crosses or ends at a reference or a
2059
- // collection, or reads one as a member, gets `__scope: { id }` on this call's copy, before middleware.
2060
- // After middleware, the query's own scope and joined scopes, the runtime checks that every marker it
2061
- // issued is still there and that no other one is, gates the final arguments, then fills each marker
2062
- // with the raw find scope arguments of every type the construct reads through, `__scope: { id, scopes:
2063
- // { [path]: args | null } }`, which the compilers apply as scoped joins and frames, so that a record
2064
- // those scopes hide reads as missing. Constructs without a marker are trusted and read stored data.
2065
- // Markers live only on the terms and group conditions collectQueryOperators returns; a `__scope`
2066
- // anywhere else, inside a value for one, is never read.
2345
+ // Client scope provenance is carried by terms, conditions and explicit frames, never by plain
2346
+ // logical groups or values. Only reads of registered scoped targets are marked. Middleware sees
2347
+ // ordinal IDs; after detach and final validation, fill adds relative target-scope arguments. The
2348
+ // planner/compiler rewrite, not the runtime, splits those keys across implicit collection frames.
2067
2349
  const SCOPE_MARKER = '__scope';
2068
2350
  const DETACHED_ADVICE = 'middleware and scopes must keep the client\'s filter objects: copy them instead of rebuilding '
2069
2351
  + 'them, and append restrictions instead of assigning over them (docs/guide/middleware.md)';
2070
2352
  const detachedClientFilter = (message) => new SimfinityError(`${message}; ${DETACHED_ADVICE}`, 'DETACHED_CLIENT_FILTER', 500);
2071
2353
  const hasScopeMarker = (construct) => construct != null && Object.hasOwn(construct, SCOPE_MARKER);
2072
2354
 
2073
- // Marks the client's constructs that read references or collections, from the gate's conditions on the
2074
- // client's arguments, then applies maxReferenceFrames to the distinct (kind, path) pairs of their
2075
- // scoped joins and frames in these conditions, so every 2a code comes first. Returns the issued
2076
- // markers, as id -> the client's operator and path, or null when the adapter does not compile
2077
- // reference emptiness. Ids are ordinals in the order of the conditions, so identical arguments get
2078
- // identical markers and sibling collection reads keep one batch key. With `issued`, the markers of
2079
- // the call so far, the ids continue on that map, so that a view's `where`, marked after the client's
2080
- // arguments, shares them; the limit still applies to each set of conditions on its own.
2081
- const markClientConstructs = (checked, issued) => {
2082
- if (!supportsReferenceEmptiness) return null;
2083
- const markers = issued ?? new Map();
2084
- const pairs = new Set();
2085
- for (const { entry, shape } of checked) {
2086
- const { construct } = entry;
2087
- if (!shape || !construct || hasScopeMarker(construct)) continue;
2088
- const { joins, frames } = emptinessScopePaths(shape, entry.segments);
2089
- if (!joins.length && !frames.length) continue;
2090
- const id = markers.size;
2355
+ // Relation metadata is position-relative when used for fill and absolute for eligibility/recording.
2356
+ const pathRelations = (gqltype, segments) => {
2357
+ const found = [];
2358
+ let insideList = false;
2359
+ walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
2360
+ const relation = field.extensions?.relation;
2361
+ const list = !!getListShape(field.type);
2362
+ if (relation && !relation.embedded) found.push({
2363
+ path: segments.slice(0, index + 1).join('.'), index,
2364
+ typeName: getNamedType(field.type).name, collection: list, insideList, owner: type.name, fieldName,
2365
+ });
2366
+ // A collection enters an element frame; only embedded lists make vector reference joins.
2367
+ if (relation && !relation.embedded && list) insideList = false;
2368
+ else if (!relation || relation.embedded) insideList ||= list;
2369
+ });
2370
+ return found;
2371
+ };
2372
+
2373
+ // The walker retains scalar-filter terms for capability/auth inspection, but no compiler consumes
2374
+ // them. Such positions cannot establish client provenance, including when aliased with a predicate.
2375
+ const ignoredScalarTerm = (gqltype, entry) => {
2376
+ if (entry.form !== 'term' || entry.frames.length || typeof entry.field !== 'string') return false;
2377
+ const segments = entry.field.split('.');
2378
+ let scalar = false;
2379
+ walkQueryPath(gqltype, segments, ({ field, index }) => {
2380
+ if (index === segments.length - 1) scalar = !isObjectType(getNamedType(field.type));
2381
+ });
2382
+ return scalar;
2383
+ };
2384
+
2385
+ const ordinaryFrameCarrier = (gqltype, entry, J) => supportsQuantifiers && entry.construct
2386
+ && !ignoredScalarTerm(gqltype, entry)
2387
+ && entry.form !== 'beside-terms' && (entry.frames.length || pathRelations(gqltype, entry.segments)
2388
+ .some(({ path, collection }) => collection && !J.has(path)));
2389
+
2390
+ // Eligibility is per carrier: ordinary legacy reference-only/all-J predicates keep joined scopes.
2391
+ // Enclosing explicit frames inherit provenance from marked descendants, independently of unit counts.
2392
+ // A view continues issuance on the same map, but checks its own compiled-unit budget (as in 3.12.0).
2393
+ const markClientConstructs = (checked, { aggregation, issued = new Map() } = {}) => {
2394
+ const { gqltype, args, operation, conditions } = checked;
2395
+ const clientJ = operation === 'aggregate' ? aggregateJoinPrefixes(aggregation, { gqltype }) : new Set();
2396
+ if (!supportsReferenceEmptiness) return { issued: null, clientJ };
2397
+ const mark = (construct, origin) => {
2398
+ if (!construct || hasScopeMarker(construct)) return;
2399
+ const id = issued.size;
2091
2400
  construct[SCOPE_MARKER] = { id };
2092
- markers.set(id, { operator: entry.operator, path: entry.path });
2093
- for (const path of joins) pairs.add(`join ${path}`);
2094
- for (const path of frames) pairs.add(`frame ${path}`);
2401
+ issued.set(id, origin);
2402
+ };
2403
+ for (const { entry, shape } of conditions) {
2404
+ const { construct } = entry;
2405
+ if (!construct || !(shape || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
2406
+ const targets = pathRelations(gqltype, entry.segments).map(({ typeName }) => typeName);
2407
+ if (shape) targets.push(...Object.values(emptinessScopeTargets(shape, entry.relative)));
2408
+ if (!targets.some((name) => getScope(typesDict.types[name]?.gqltype, 'find'))) continue;
2409
+ const origin = { operator: entry.operator ?? entry.quantifier, path: entry.path };
2410
+ mark(construct, origin);
2411
+ const prefix = [];
2412
+ for (const frame of entry.frames) {
2413
+ prefix.push(...frame.path.split('.'));
2414
+ mark(frame.construct, { operator: frame.type, path: prefix.join('.') });
2415
+ }
2095
2416
  }
2417
+ const { count } = frameUnits(args, queryFrameContext(gqltype, args, operation));
2096
2418
  const limit = getQueryMaxReferenceFrames();
2097
- if (pairs.size > limit) {
2098
- throw new SimfinityError(`Emptiness conditions through references and collections need ${pairs.size} scoped joins and `
2099
- + 'frames in one set of conditions (the client\'s arguments of a query field, or a view\'s where), more than the limit '
2100
- + `of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
2419
+ if (count > limit) {
2420
+ throw new SimfinityError(`The client's conditions need ${count} frames and scoped joins in one query field, more than the limit of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
2101
2421
  }
2102
- return markers;
2422
+ return { issued, clientJ };
2103
2423
  };
2104
2424
 
2105
- // The detach check: every marker issued in this call sits, well formed (an object whose only own key
2106
- // is its id), on a term or group condition of the final arguments, possibly on several when middleware
2107
- // copied or duplicated the construct; no other marker sits on one.
2108
- const assertClientMarkers = (args, operation, issued) => {
2425
+ // Every issued ID must survive on a valid carrier, with no forged or prefilled marker. Marked
2426
+ // descendants may only live inside marked client frames; top-frame collection prefixes keep J.
2427
+ const assertClientMarkers = (gqltype, args, operation, issued, clientJ) => {
2428
+ const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
2109
2429
  const found = new Set();
2110
- for (const { construct, operator, path } of collectQueryOperators(args, operation)) {
2430
+ for (const entry of collectQueryOperators(args, operation)) {
2431
+ const { construct, operator, path } = entry;
2111
2432
  if (!hasScopeMarker(construct)) continue;
2112
2433
  const marker = construct[SCOPE_MARKER];
2113
2434
  const origin = isFilterObject(marker) ? issued.get(marker.id) : undefined;
@@ -2118,6 +2439,17 @@ const assertClientMarkers = (args, operation, issued) => {
2118
2439
  if (!origin) {
2119
2440
  throw detachedClientFilter(`The ${operator} condition on ${path} carries a client scope marker that this query field did not issue`);
2120
2441
  }
2442
+ if (ignoredScalarTerm(gqltype, entry)) {
2443
+ throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is in ignored scalar filter terms`);
2444
+ }
2445
+ if (entry.frames.some((frame) => !hasScopeMarker(frame.construct))) {
2446
+ throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is inside a trusted frame`);
2447
+ }
2448
+ if (supportsQuantifiers && !entry.frames.length && entry.form !== 'quantified' && !isEmptinessOperator(operator)) {
2449
+ for (const relation of pathRelations(gqltype, entry.segments)) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
2450
+ throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} changed aggregate join prefixes`);
2451
+ }
2452
+ }
2121
2453
  found.add(marker.id);
2122
2454
  }
2123
2455
  for (const [id, origin] of issued) {
@@ -2138,6 +2470,15 @@ const relationOnPath = (gqltype, segments) => {
2138
2470
  return found;
2139
2471
  };
2140
2472
 
2473
+ const scopeFilterArgs = (scopeArgs) => {
2474
+ const kept = {};
2475
+ for (const [name, value] of Object.entries(scopeArgs)) {
2476
+ if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
2477
+ else if (value != null) assertScopeControlArg(name, value);
2478
+ }
2479
+ return kept;
2480
+ };
2481
+
2141
2482
  // The find scope arguments of a type that a client's constructs read through, once per call and type
2142
2483
  // (`cache`): its find scope runs with empty arguments, operation 'find' and the request context, as
2143
2484
  // joined scopes do; its sort and pagination are checked for shape and dropped; the rest gets the final
@@ -2150,11 +2491,7 @@ const clientScopeArgs = async (typeName, context, cache) => {
2150
2491
  if (getScope(target?.gqltype, 'find')) {
2151
2492
  const scopeArgs = {};
2152
2493
  await executeScope({ type: target, args: scopeArgs, operation: 'find', context });
2153
- const kept = {};
2154
- for (const [name, value] of Object.entries(scopeArgs)) {
2155
- if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
2156
- else if (value != null) assertScopeControlArg(name, value);
2157
- }
2494
+ const kept = scopeFilterArgs(scopeArgs);
2158
2495
  assertStoredQueryPaths(target.gqltype, kept, 'find');
2159
2496
  assertQueryConditions(target.gqltype, kept, 'find');
2160
2497
  if (Object.values(kept).some((value) => value != null && !(Array.isArray(value) && !value.length))) result = kept;
@@ -2163,19 +2500,15 @@ const clientScopeArgs = async (typeName, context, cache) => {
2163
2500
  return result;
2164
2501
  };
2165
2502
 
2166
- // The fill, on the final arguments the gate passed (`checked`). Each marked object is read at each of
2167
- // its final positions, with its final operator and path:
2168
- // - an emptiness condition that crosses, ends at or has as members references or collections gets a
2169
- // fresh `__scope: { id, scopes }`, one key per scoped join and frame path, with the union of the
2170
- // keys of every position when middleware placed the object at several;
2171
- // - an emptiness condition narrowed to the record's own fields loses its marker, and is trusted;
2172
- // - another operator loses it too, unless its path still crosses or ends at a reference or a
2173
- // collection: no joined scope was recorded for it, so it fails with DETACHED_CLIENT_FILTER.
2174
- // An object that reads references at one position and none at another fails the same way, since one
2175
- // marker cannot serve both. Every check comes before any target scope runs.
2503
+ // Preflight every final position before running target scopes: union relative keys, reject target
2504
+ // collisions and mixed reads, and retain the compiler's rejection of marked legacy predicates.
2505
+ // J collection keys are null; anchor references remain legacy joins. Keyless carriers lose their
2506
+ // marker. One per-call type cache serves all remaining keys, separately from joined-scope execution.
2176
2507
  const fillClientScopes = async (gqltype, checked, context, issued) => {
2508
+ const { conditions, args, operation } = checked;
2509
+ const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
2177
2510
  const marked = new Map();
2178
- for (const item of checked) {
2511
+ for (const item of conditions) {
2179
2512
  const { construct } = item.entry;
2180
2513
  if (!hasScopeMarker(construct)) continue;
2181
2514
  if (!marked.has(construct)) marked.set(construct, []);
@@ -2187,18 +2520,29 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
2187
2520
  const origin = issued.get(id);
2188
2521
  const targets = {};
2189
2522
  const unread = [];
2190
- for (const { entry, shape } of positions) {
2191
- if (!entry.emptiness) {
2523
+ const vectors = [];
2524
+ for (const { entry, shape, type } of positions) {
2525
+ const relations = pathRelations(type, entry.relative);
2526
+ if (!shape && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
2192
2527
  const relation = relationOnPath(gqltype, entry.segments);
2193
- if (relation) {
2194
- throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
2195
- + `${entry.path}, which reads through ${relation} without its scope`);
2528
+ if (relation) throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
2529
+ + `${entry.path}, which reads through ${relation} without its scope`);
2530
+ }
2531
+ const rootShorthand = !entry.frames.length && entry.form !== 'quantified' && !shape;
2532
+ const anchored = rootShorthand && relations.some(({ collection, path }) => collection && J.has(path));
2533
+ const cut = anchored ? relations.find(({ collection, path }) => collection && !J.has(path)) : null;
2534
+ const keys = shape ? emptinessScopeTargets(shape, entry.relative) : Object.fromEntries(relations
2535
+ .filter((relation) => !anchored || relation.collection || (cut && relation.index >= cut.index))
2536
+ .map(({ path, typeName, collection }) => [path, rootShorthand && collection && J.has(path) ? null : typeName]));
2537
+ for (const [key, target] of Object.entries(keys)) {
2538
+ if (Object.hasOwn(targets, key) && targets[key] !== target) {
2539
+ throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} uses ${key} for different scope targets`);
2196
2540
  }
2197
- unread.push(entry.path);
2198
- } else {
2199
- const keys = shape ? emptinessScopeTargets(shape, entry.segments) : {};
2200
- if (Object.keys(keys).length) Object.assign(targets, keys);
2201
- else unread.push(entry.path);
2541
+ targets[key] = target;
2542
+ }
2543
+ if (!Object.keys(keys).length) unread.push(entry.path);
2544
+ for (const relation of relations) if (!relation.collection && relation.insideList && Object.hasOwn(keys, relation.path)) {
2545
+ vectors.push({ ...relation, absolute: [...entry.segments.slice(0, -entry.relative.length), ...relation.path.split('.')].join('.') });
2202
2546
  }
2203
2547
  }
2204
2548
  const paths = Object.keys(targets);
@@ -2206,16 +2550,16 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
2206
2550
  throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is used where it reads `
2207
2551
  + `references or collections and where it reads none (${unread.join(', ')})`);
2208
2552
  }
2209
- fills.push({ construct, id, targets: paths.length ? targets : null });
2553
+ fills.push({ construct, id, targets: paths.length ? targets : null, vectors });
2210
2554
  }
2211
2555
  const cache = new Map();
2212
- for (const { construct, id, targets } of fills) {
2213
- if (!targets) {
2214
- delete construct[SCOPE_MARKER];
2215
- continue;
2216
- }
2556
+ for (const { construct, id, targets, vectors } of fills) {
2557
+ if (!targets) { delete construct[SCOPE_MARKER]; continue; }
2217
2558
  const scopes = {};
2218
- for (const [path, typeName] of Object.entries(targets)) scopes[path] = await clientScopeArgs(typeName, context, cache);
2559
+ for (const [path, typeName] of Object.entries(targets)) scopes[path] = typeName === null ? null : await clientScopeArgs(typeName, context, cache);
2560
+ for (const relation of vectors) if (scopes[relation.path] !== null) {
2561
+ throw forbiddenPath(`Query path ${relation.absolute} enters ${relation.typeName}, whose find scope restricts the relation ${relation.owner}.${relation.fieldName} inside a list`);
2562
+ }
2219
2563
  construct[SCOPE_MARKER] = { id, scopes };
2220
2564
  }
2221
2565
  };
@@ -2225,8 +2569,8 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
2225
2569
  // the adapter compiles reference emptiness. get_by_id builds its own arguments and never runs it.
2226
2570
  // Returns the fill's promise, or null when no marker was issued, so that a call without one reaches
2227
2571
  // the adapter after as many promise jobs as before.
2228
- const settleClientArgs = (gqltype, params, operation, issued) => {
2229
- if (issued) assertClientMarkers(params.args, operation, issued);
2572
+ const settleClientArgs = (gqltype, params, operation, { issued, clientJ }) => {
2573
+ if (issued) assertClientMarkers(gqltype, params.args, operation, issued, clientJ);
2230
2574
  assertStoredQueryPaths(gqltype, params.args, operation);
2231
2575
  const checked = assertQueryConditions(gqltype, params.args, operation);
2232
2576
  return issued?.size ? fillClientScopes(gqltype, checked, params.context, issued) : null;
@@ -2304,6 +2648,29 @@ const loadReference = (type, id, requiredId, context) => {
2304
2648
  });
2305
2649
  };
2306
2650
 
2651
+ // The query arguments whose get_by_id scope function threw in findVisibleById. The error propagates
2652
+ // as it was thrown; the conflict path (reportEarlyConflict) tells it from errors of the gates and of
2653
+ // adapter.find this way. Each caller (resolveById and reportEarlyConflict) allocates fresh args
2654
+ // and calls findVisibleById exactly once with that object; never reuse marked args for another read.
2655
+ const failedByIdScopes = new WeakSet();
2656
+
2657
+ // The record that the type's get_by_id scope shows the caller, or null. `args` holds an `id` EQ
2658
+ // condition, to which the scope adds its own; the gates check them as for every read, and adapter.find
2659
+ // reads in `session`, pinned to `requiredId`. get_by_id middleware is not run here; resolveById runs it
2660
+ // first.
2661
+ const findVisibleById = async (type, args, session, context, requiredId) => {
2662
+ try {
2663
+ await executeScope({ type, args, operation: 'get_by_id', context });
2664
+ } catch (error) {
2665
+ failedByIdScopes.add(args);
2666
+ throw error;
2667
+ }
2668
+ assertStoredQueryPaths(type.gqltype, args, 'find');
2669
+ assertQueryConditions(type.gqltype, args, 'find');
2670
+ const results = await adapter.find(type.model, type.gqltype, args, session, { requiredId, context });
2671
+ return results[0] || null;
2672
+ };
2673
+
2307
2674
  // `requiredId` marks a generated single-reference read. Middleware still runs per call; then, when the
2308
2675
  // adapter reads by ID in batches, and unless the type has a get_by_id scope or middleware changed the
2309
2676
  // ID, the read joins the request's batch.
@@ -2317,12 +2684,7 @@ const resolveById = async (type, args, context, requiredId) => {
2317
2684
  }
2318
2685
  return adapter.getById(type.model, args.id, null, { requiredId, context });
2319
2686
  }
2320
- const queryArgs = { id: { operator: 'EQ', value: args.id } };
2321
- await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
2322
- assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
2323
- assertQueryConditions(type.gqltype, queryArgs, 'find');
2324
- const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
2325
- return results[0] || null;
2687
+ return findVisibleById(type, { id: { operator: 'EQ', value: args.id } }, null, context, requiredId);
2326
2688
  };
2327
2689
 
2328
2690
  // Batched collection reads. A generated collection field reads its children with one
@@ -2713,10 +3075,13 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2713
3075
  : `mutation that takes no input or an input of its own, since getInputType(${type.gqltype.name}) `
2714
3076
  + 'has no fields.'));
2715
3077
  }
3078
+ // A protected type is deleted at the version the client read (#98).
3079
+ const deleteArgs = { id: { type: new GraphQLNonNull(GraphQLID) } };
3080
+ if (isProtected(type.gqltype)) deleteArgs.version = { type: new GraphQLNonNull(RecordVersion) };
2716
3081
  rootQueryArgs.fields[`delete${type.simpleEntityEndpointName}`] = {
2717
3082
  type: type.gqltype,
2718
3083
  description: 'delete',
2719
- args: { id: { type: new GraphQLNonNull(GraphQLID) } },
3084
+ args: deleteArgs,
2720
3085
  extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.DELETE } },
2721
3086
  async resolve(parent, args, context) {
2722
3087
  const params = {
@@ -2727,8 +3092,10 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2727
3092
  };
2728
3093
 
2729
3094
  await executeMiddleware(params);
3095
+ // A protected delete takes the version as middleware left it.
3096
+ const target = isProtected(type.gqltype) ? { id: args.id, version: args.version } : args.id;
2730
3097
  return executeOperation(type.model, type.gqltype, type.controller,
2731
- args.id, operations.DELETE, null, null, context, null);
3098
+ target, operations.DELETE, null, null, context, null);
2732
3099
  },
2733
3100
  };
2734
3101
  }
@@ -2820,7 +3187,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2820
3187
  // without one still reaches the adapter after as many promise jobs as before it was shared.
2821
3188
  const prepareRead = async (type, args, context, operation, { entry, extend } = {}) => {
2822
3189
  const joinedScopes = inspectClientPaths(type.gqltype, args, operation);
2823
- const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation));
3190
+ const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation), { aggregation: args.aggregation });
2824
3191
  const params = entry === undefined
2825
3192
  ? { type, args, operation, context }
2826
3193
  : { type, args, operation, entry, context };
@@ -2869,13 +3236,13 @@ const splitViewArgs = (paramNames, args) => {
2869
3236
 
2870
3237
  const invalidWhere = (view, detail) => new SimfinityError(`View ${view.name} where ${detail}`, 'INVALID_VIEW', 500);
2871
3238
 
2872
- // The groups a view's `where` returned are plain objects whose `conditions`, `AND` and `OR`, when set,
3239
+ // The groups a view's `where` returned are plain objects whose condition and Boolean/quantified lists, when set,
2873
3240
  // are arrays: of plain objects with a field name, and of groups like these. collectQueryPathEntries
2874
3241
  // skips anything else silently, so this comes before their paths are read. Only the GraphQL input
2875
- // fields are structural keys; condition values stay opaque. A condition never carries a client scope
2876
- // marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
2877
- const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
2878
- const groupFields = QLFilterGroup.getFields();
3242
+ // fields are structural keys; condition values stay opaque. Conditions and quantified groups never
3243
+ // carry a client scope marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
3244
+ const assertViewGroupShapes = (view, groups, ancestors = new Set(), quantified = false) => {
3245
+ const groupFields = (quantified ? QLQuantifiedGroup : QLFilterGroup).getFields();
2879
3246
  const conditionFields = QLFilterCondition.getFields();
2880
3247
  for (const group of groups) {
2881
3248
  if (!isNonNullPlainObject(group)) throw invalidWhere(view, 'returned a group that is not a plain object');
@@ -2883,7 +3250,11 @@ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
2883
3250
  for (const key of Object.keys(group)) {
2884
3251
  if (!Object.hasOwn(groupFields, key)) throw invalidWhere(view, `returned a group with unknown key ${key}`);
2885
3252
  }
3253
+ if (quantified && (typeof group.path !== 'string' || group.path.length === 0)) {
3254
+ throw invalidWhere(view, 'returned a quantified group without a non-empty path');
3255
+ }
2886
3256
  for (const key of Object.keys(groupFields)) {
3257
+ if (key === 'path') continue;
2887
3258
  if (group[key] != null && !Array.isArray(group[key])) throw invalidWhere(view, `returned a group whose ${key} is not an array`);
2888
3259
  }
2889
3260
  for (const condition of group.conditions ?? []) {
@@ -2899,6 +3270,7 @@ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
2899
3270
  }
2900
3271
  ancestors.add(group);
2901
3272
  assertViewGroupShapes(view, [...(group.AND ?? []), ...(group.OR ?? [])], ancestors);
3273
+ for (const key of QUANTIFIED_LISTS) assertViewGroupShapes(view, group[key] ?? [], ancestors, true);
2902
3274
  ancestors.delete(group);
2903
3275
  }
2904
3276
  };
@@ -3317,6 +3689,108 @@ const warnUndeclaredVirtualFields = () => {
3317
3689
  }
3318
3690
  };
3319
3691
 
3692
+ // The types a model stores: the registered types and the embedded types they reach at any depth,
3693
+ // readOnly fields included, as assertPlainConnectionFields visits them.
3694
+ const collectStoredTypes = () => {
3695
+ const stored = new Set();
3696
+ const visit = (gqltype) => {
3697
+ if (stored.has(gqltype)) return;
3698
+ stored.add(gqltype);
3699
+ for (const field of Object.values(gqltype.getFields())) {
3700
+ const named = field.extensions?.relation?.embedded && getNamedType(field.type);
3701
+ if (named instanceof GraphQLObjectType) visit(named);
3702
+ }
3703
+ };
3704
+ for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
3705
+ return [...stored];
3706
+ };
3707
+
3708
+ const invalidModel = (message) => new SimfinityError(message, 'INVALID_MODEL', 400);
3709
+
3710
+ // Optimistic concurrency (#98), declared per type with `extensions.concurrency`. These checks run in
3711
+ // this order, after stored identities are known and before adapter.prepare(), so a rejected schema
3712
+ // changes nothing. `applicationTypes` are the roots of the schema's types. Returns the registrations of
3713
+ // the protected types and whether the included roots reserve the version names.
3714
+ const assertConcurrencyDeclarations = (applicationTypes) => {
3715
+ const registrations = Object.values(typesDict.types);
3716
+ // The shape of every declaration.
3717
+ const protectedTypes = registrations.filter(({ gqltype }) => concurrencyDeclared(gqltype));
3718
+ // Generated inputs, models and stored data follow the protection a type had when the first schema
3719
+ // containing it was created, and an injected field is never removed, so neither can change later.
3720
+ // Inputs or a model that an earlier createSchema() built without the field also fix protection,
3721
+ // even if the call failed afterwards; a model supplied to connect() does not.
3722
+ for (const registration of registrations) {
3723
+ const { gqltype } = registration;
3724
+ const declared = protectedTypes.includes(registration);
3725
+ const builtWithoutField = !isProtected(gqltype)
3726
+ && (!!registration.inputType || generatedModels.has(registration.model));
3727
+ if (declared && protectionStatus.get(gqltype) === false) {
3728
+ throw invalidModel(`${gqltype.name} declares optimistic concurrency after a schema was created without it; `
3729
+ + `declare it before the first createSchema() that contains ${gqltype.name}`);
3730
+ }
3731
+ if (declared && builtWithoutField) {
3732
+ throw invalidModel(`${gqltype.name} declares optimistic concurrency after its inputs or generated model were built without it; `
3733
+ + `declare it before the first createSchema() attempt that builds ${gqltype.name}`);
3734
+ }
3735
+ if (!declared && isProtected(gqltype)) {
3736
+ throw invalidModel(`${gqltype.name} no longer declares optimistic concurrency, but Simfinity already added its `
3737
+ + 'version field; keep extensions.concurrency on a type once it has that field');
3738
+ }
3739
+ }
3740
+ // Another runtime that registers a protected type injects its field when that runtime creates a
3741
+ // schema, which would change a schema already built here, so this schema must register every
3742
+ // protected type it contains, whatever the adapter supports.
3743
+ const reachedTypes = collectOutputTypes(applicationTypes);
3744
+ for (const type of reachedTypes) {
3745
+ if (type instanceof GraphQLObjectType && typesDict.types[type.name]?.gqltype !== type && isProtectedType(type)) {
3746
+ throw invalidModel(`${type.name} declares optimistic concurrency, so a schema that contains it must register it `
3747
+ + 'in this runtime, with connect() or as a relation target');
3748
+ }
3749
+ }
3750
+ // An embedded value's changes belong to the revision of the record that embeds it, wherever a model
3751
+ // stores it.
3752
+ const storedTypes = collectStoredTypes();
3753
+ const storedFields = storedTypes.flatMap((gqltype) => Object.entries(gqltype.getFields())
3754
+ .map(([fieldName, field]) => ({ gqltype, fieldName, field })));
3755
+ for (const { gqltype, fieldName, field } of storedFields) {
3756
+ const named = field.extensions?.relation?.embedded && getNamedType(field.type);
3757
+ if (named instanceof GraphQLObjectType && isProtectedType(named)) {
3758
+ throw invalidModel(versionEmbeddedMisuse(named.name, `${gqltype.name}.${fieldName}`));
3759
+ }
3760
+ }
3761
+ // Only an entity has a record of its own whose version can advance.
3762
+ const nonEntity = protectedTypes.find(({ storedIdentity }) => storedIdentity !== true);
3763
+ if (nonEntity) {
3764
+ throw invalidModel(`${nonEntity.gqltype.name} declares optimistic concurrency, so it must be an entity: register it `
3765
+ + 'with connect() or make it a relation target');
3766
+ }
3767
+ // Nothing else may be stored, or link records, under the version field's name.
3768
+ for (const { gqltype } of protectedTypes) {
3769
+ const collision = versionFieldCollision(gqltype);
3770
+ if (collision) throw invalidModel(collision);
3771
+ }
3772
+ for (const { gqltype, fieldName, field } of storedFields) {
3773
+ const collision = versionConnectionCollision(gqltype, fieldName, field);
3774
+ if (collision) throw invalidModel(collision);
3775
+ }
3776
+ // RecordVersion types managed version fields only, with or without protected types; registered
3777
+ // operations may still use it in their own input and output types.
3778
+ for (const gqltype of storedTypes) {
3779
+ const misuse = recordVersionFieldMisuse(gqltype);
3780
+ if (misuse) throw invalidModel(misuse);
3781
+ }
3782
+ const reserveVersionNames = protectedTypes.some(({ gqltype }) => reachedTypes.includes(gqltype));
3783
+ if (protectedTypes.length && !writeFeatures.optimisticConcurrency) {
3784
+ throw invalidModel(`The database adapter does not support optimistic concurrency, which ${protectedTypes[0].gqltype.name} `
3785
+ + 'declares (extensions.concurrency)');
3786
+ }
3787
+ // A clash that only generated types or the fields of an input object reach is found when the schema
3788
+ // is constructed.
3789
+ const clash = reserveVersionNames && versionTypeClash(applicationTypes, { inputFields: false });
3790
+ if (clash) throw reservedVersionTypeNameError(clash);
3791
+ return { protectedTypes, reserveVersionNames };
3792
+ };
3793
+
3320
3794
  const getRegistrations = () => Object.values(typesDict.types);
3321
3795
 
3322
3796
  const includedOperationTypes = (registrations, includedOperations) => Object.entries(registrations)
@@ -3324,7 +3798,8 @@ const includedOperationTypes = (registrations, includedOperations) => Object.ent
3324
3798
  .flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean));
3325
3799
 
3326
3800
  // Application types that a schema with these allowlists contains, whatever else it reaches: the
3327
- // endpoint types of its queries and mutations, and the input and output types of its custom operations.
3801
+ // endpoint types of its queries and mutations, the input and output types of its custom operations,
3802
+ // and the scalar/enum parameters of included named views.
3328
3803
  const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations, includedCustomQueries) => [
3329
3804
  ...Object.values(typesDict.types)
3330
3805
  .filter((type) => type.endpoint
@@ -3333,6 +3808,9 @@ const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, inc
3333
3808
  .map((type) => type.gqltype),
3334
3809
  ...includedOperationTypes(registeredMutations, includedCustomMutations),
3335
3810
  ...includedOperationTypes(registeredQueries, includedCustomQueries),
3811
+ ...Object.values(registeredViews)
3812
+ .filter(({ type }) => !shouldNotBeIncludedInSchema(includedQueryTypes, type))
3813
+ .flatMap(({ params }) => Object.values(params).map(({ type }) => getNamedType(type))),
3336
3814
  ];
3337
3815
 
3338
3816
  const queryNameConflict = (name) => new SimfinityError(
@@ -3374,7 +3852,9 @@ const assertViewsAvailable = (includedQueryTypes, includedCustomQueries) => {
3374
3852
  if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(paramName) || paramName.startsWith('__')) {
3375
3853
  throw invalidView(`${name} param ${paramName} requires a valid GraphQL name that does not start with __`);
3376
3854
  }
3377
- if (Object.hasOwn(fields, paramName)) {
3855
+ // A protected type's managed version is injected later in createSchema(), but its name is
3856
+ // already unavailable to view params on the first schema, as on every subsequent schema.
3857
+ if (Object.hasOwn(fields, paramName) || (paramName === 'version' && isProtectedType(type))) {
3378
3858
  throw invalidView(`${name} param ${paramName} has the name of a field of ${type.name}`);
3379
3859
  }
3380
3860
  if (LIST_QUERY_ARGS.includes(paramName)) {
@@ -3428,16 +3908,21 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3428
3908
  // An application type with the name of a field metadata type that an earlier schema fixed; one that
3429
3909
  // only generated types or the fields of an input object reach is found when the schema is
3430
3910
  // constructed.
3431
- assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes,
3432
- includedCustomMutations, includedCustomQueries));
3911
+ const applicationTypes = includedApplicationTypes(includedQueryTypes, includedMutationTypes,
3912
+ includedCustomMutations, includedCustomQueries);
3913
+ assertFieldMetadataNamesFree(applicationTypes);
3433
3914
  const referencedTypes = markReferencedTypesForModelGeneration();
3434
3915
  // Entities, which keep their own stored identity: connect() types and relation targets.
3435
3916
  Object.values(typesDict.types).forEach((typeInfo) => {
3436
3917
  typeInfo.storedIdentity = typeInfo.endpoint === true || referencedTypes.has(typeInfo);
3437
3918
  });
3919
+ const { protectedTypes, reserveVersionNames } = assertConcurrencyDeclarations(applicationTypes);
3438
3920
  if (adapter.prepare) {
3439
3921
  adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
3440
3922
  }
3923
+ // prepare() describes protected types from their declaration. Models, inputs, resolvers and query
3924
+ // arguments are built from here on, so they all see the field.
3925
+ protectedTypes.forEach(({ gqltype }) => injectVersionField(gqltype));
3441
3926
 
3442
3927
  Object.values(typesDict.types).forEach((typeInfo) => {
3443
3928
  if (typeInfo.gqltype && !typeInfo.model) {
@@ -3448,6 +3933,8 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3448
3933
  } else if (typeInfo.needsModel) {
3449
3934
  typeInfo.model = adapter.createModel(typeInfo.gqltype, null, { createCollection: false });
3450
3935
  }
3936
+ const { model } = typeInfo;
3937
+ if (model !== null && (typeof model === 'object' || typeof model === 'function')) generatedModels.add(model);
3451
3938
  }
3452
3939
  });
3453
3940
 
@@ -3492,7 +3979,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3492
3979
  includedCustomQueries);
3493
3980
  }
3494
3981
 
3495
- const schema = constructSchema(query, mutation);
3982
+ const schema = constructSchema(query, mutation, reserveVersionNames);
3496
3983
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
3497
3984
  // custom operation result, so another runtime cannot later generate resolvers this schema executes.
3498
3985
  schemaTypes.forEach((type) => {
@@ -3500,6 +3987,10 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3500
3987
  runtimeBoundTypes.set(type, runtimeIdentity);
3501
3988
  }
3502
3989
  });
3990
+ // The schema exists, so each registered type's protection is fixed from now on.
3991
+ Object.values(typesDict.types).forEach(({ gqltype }) => {
3992
+ if (!protectionStatus.has(gqltype)) protectionStatus.set(gqltype, isProtected(gqltype));
3993
+ });
3503
3994
  return schema;
3504
3995
  };
3505
3996
 
@@ -3721,6 +4212,8 @@ const connect = (model, gqltype, simpleEntityEndpointName,
3721
4212
  };
3722
4213
  assertNotBoundElsewhere(gqltype);
3723
4214
  validateScope(gqltype);
4215
+ // The declaration's shape only; it reads extensions, never the fields (#98).
4216
+ concurrencyDeclared(gqltype);
3724
4217
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
3725
4218
 
3726
4219
  invalidateCollectionInputs(gqltype);
@@ -3751,6 +4244,7 @@ const addNoEndpointType = (gqltype) => {
3751
4244
  gqltype, endpoint: false, model: null, needsModel,
3752
4245
  };
3753
4246
  validateScope(gqltype);
4247
+ concurrencyDeclared(gqltype);
3754
4248
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
3755
4249
 
3756
4250
  invalidateCollectionInputs(gqltype);
@@ -3764,7 +4258,9 @@ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
3764
4258
 
3765
4259
  // `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
3766
4260
  // A field named like a generated argument gets no top-level filter either; it is reported once.
3767
- // A virtual field is not part of the query language, so it gets no filter at all.
4261
+ // A virtual field is not part of the query language, so it gets no filter at all, and neither does the
4262
+ // version field of a protected type (#98): a MongoDB record without a stored version reads as 1, which
4263
+ // a filter could not match.
3768
4264
  const createArgsForQuery = (gqltype, omit = null) => {
3769
4265
  const argsObject = {};
3770
4266
  const argTypes = gqltype.getFields();
@@ -3777,7 +4273,7 @@ const createArgsForQuery = (gqltype, omit = null) => {
3777
4273
  }
3778
4274
 
3779
4275
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
3780
- if (fieldEntryName === omit || isVirtualField(fieldEntry)) continue;
4276
+ if (fieldEntryName === omit || isVirtualField(fieldEntry) || isVersionField(fieldEntry)) continue;
3781
4277
  let filterType = null;
3782
4278
 
3783
4279
  if (fieldEntry.type instanceof GraphQLScalarType