@simtlix/simfinity-core 3.11.0 → 3.13.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
@@ -6,7 +6,7 @@ import {
6
6
  GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
7
7
  GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
8
8
  GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
9
- Kind, getNamedType, isObjectType, isOutputType,
9
+ Kind, getNamedType, isEnumType, isListType, isNonNullType, isObjectType, isOutputType, isScalarType,
10
10
  } from 'graphql';
11
11
 
12
12
  import SimfinityError from './errors/simfinity.error.js';
@@ -33,6 +33,12 @@ import {
33
33
  import {
34
34
  fieldMetadataNamesFixed, fieldMetadataTypes, fixFieldMetadataNames, renameFieldMetadataTypes,
35
35
  } from './introspection.js';
36
+ import {
37
+ MAX_RECORD_VERSION, RecordVersion, RecordVersionRef, assertVersionInput, concurrencyDeclared, createVersionFieldConfig,
38
+ isProtectedType, isVersionField, readStoredVersion, recordVersionFieldMisuse, retainedStoredVersion, unadvancedVersionError,
39
+ unexpectedDeleteError, versionConflictError, versionConnectionCollision, versionEmbeddedMisuse, versionExhaustedError,
40
+ versionFieldCollision, versionManagedError,
41
+ } from './record-version.js';
36
42
 
37
43
  // Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
38
44
  // created is application code, such as a masking resolver, recorded once per type so later
@@ -85,6 +91,33 @@ const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER
85
91
  // Generated inputs never write a readOnly field, nor a virtual one, which virtual implies.
86
92
  const isReadOnlyField = (field) => !!field.extensions?.readOnly || isVirtualField(field);
87
93
 
94
+ // Optimistic concurrency (#98). A type is protected once it carries the version field createSchema()
95
+ // injects, which it keeps for good, so code that builds schemas and runs requests asks this instead of
96
+ // reading the live `extensions.concurrency`.
97
+ const isProtected = (gqltype) => isVersionField(gqltype.getFields().version);
98
+ // Whether each registered type was protected when the first createSchema() that contained it returned,
99
+ // for every runtime of this copy of the core code. A failed createSchema() records nothing.
100
+ const protectionStatus = new WeakMap();
101
+
102
+ const invalidWriteFeatures = (message) => {
103
+ throw new SimfinityError(`DatabaseAdapter writeFeatures ${message}`, 'INVALID_WRITE_FEATURES', 500);
104
+ };
105
+
106
+ // Reads an adapter's `writeFeatures` declaration once, into a frozen copy. Undefined or null declares
107
+ // nothing; a plain object may declare `optimisticConcurrency` as a boolean, and its other members are
108
+ // left for later features. Anything else throws INVALID_WRITE_FEATURES (500).
109
+ const resolveWriteFeatures = (writeFeatures) => {
110
+ if (writeFeatures == null) return Object.freeze({ optimisticConcurrency: false });
111
+ if (typeof writeFeatures !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(writeFeatures))) {
112
+ invalidWriteFeatures('must be a plain object');
113
+ }
114
+ const { optimisticConcurrency } = writeFeatures;
115
+ if (optimisticConcurrency !== undefined && typeof optimisticConcurrency !== 'boolean') {
116
+ invalidWriteFeatures('optimisticConcurrency must be a boolean');
117
+ }
118
+ return Object.freeze({ optimisticConcurrency: optimisticConcurrency === true });
119
+ };
120
+
88
121
  // Query scopes restrict reads, so a scope Simfinity cannot run must fail rather than be skipped.
89
122
  const SCOPE_OPERATIONS = ['find', 'get_by_id', 'aggregate'];
90
123
  const invalidScope = (typeName, detail) => new SimfinityError(
@@ -280,6 +313,24 @@ const assertFieldMetadataNamesFree = (roots) => {
280
313
  if (clash) throw reservedTypeNameError(clash);
281
314
  };
282
315
 
316
+ // A schema with protected types contains RecordVersion, and RecordVersionRef whenever a generated input
317
+ // reaches a collection of protected items, so an application type with either name, other than the
318
+ // instances this module exports, is rejected there (#98). Like the field metadata names, they are first
319
+ // checked before anything is built, reading input objects by their own name only, and then in the whole
320
+ // schema when it is constructed.
321
+ const versionTypeClash = (roots, options) => [RecordVersion, RecordVersionRef]
322
+ .map((own) => findSchemaType(roots, own.name, own, options)).find(Boolean) || null;
323
+ // A scalar named RecordVersion with core's own description is almost always core's scalar from a
324
+ // second copy of this package, which protected types cannot work with.
325
+ const reservedVersionTypeNameError = (clash) => new SimfinityError(
326
+ clash instanceof GraphQLScalarType && clash.name === RecordVersion.name && clash.description === RecordVersion.description
327
+ ? 'RecordVersion is reserved for optimistic concurrency, and this RecordVersion comes from another copy of '
328
+ + '@simtlix/simfinity-core: two copies of the package are loaded; import RecordVersion from the facade the '
329
+ + 'application uses'
330
+ : `${clash.name} is reserved for optimistic concurrency; rename the application type`,
331
+ 'RESERVED_TYPE_NAME', 500,
332
+ );
333
+
283
334
  const buildSchemaConfig = (query, mutation) => {
284
335
  const config = { query };
285
336
  if (Object.keys(mutation.getFields()).length > 0) {
@@ -292,12 +343,15 @@ const buildSchemaConfig = (query, mutation) => {
292
343
  // those are renamed SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended
293
344
  // when the schema also has those names, in every schema the process constructs from then on. A
294
345
  // construction that fails puts the previous metadata types back, so only a schema that exists fixes
295
- // the names.
296
- const constructSchema = (query, mutation) => {
346
+ // the names. With `reserveVersionTypeNames`, for a schema with protected types, an application type
347
+ // named RecordVersion or RecordVersionRef fails, even one that only an input field reaches.
348
+ const constructSchema = (query, mutation, reserveVersionTypeNames = false) => {
297
349
  const config = buildSchemaConfig(query, mutation);
298
350
  const roots = Object.values(config);
299
351
  const clash = fieldMetadataClash(roots);
300
352
  if (clash && fieldMetadataNamesFixed()) throw reservedTypeNameError(clash);
353
+ const versionClash = reserveVersionTypeNames && versionTypeClash(roots);
354
+ if (versionClash) throw reservedVersionTypeNameError(versionClash);
301
355
  let names = null;
302
356
  if (clash) {
303
357
  const taken = (name) => findSchemaType(roots, name) !== null;
@@ -431,6 +485,21 @@ const readOwnField = markGenerated((parent, args, context, info) => {
431
485
  return typeof property === 'function' ? property.call(parent, args, context, info) : property;
432
486
  });
433
487
 
488
+ // Puts the version field into a protected type's getFields() map, after the type's own fields, unless
489
+ // the type carries it already: from an earlier schema, another runtime of this core copy that shares
490
+ // the type, or the type it was copied from. The resolver reads only the parent, so it binds the type to
491
+ // no runtime, and a record without a stored version, such as a MongoDB document written before the
492
+ // type was protected, reads as version 1. An invalid stored version is reported under the type being
493
+ // resolved, which a renamed copy shares the resolver with, or under the type it was injected into.
494
+ const injectVersionField = (gqltype) => {
495
+ if (isProtected(gqltype)) return;
496
+ const typeName = gqltype.name;
497
+ gqltype.getFields().version = createVersionFieldConfig(markGenerated((parent, args, context, info) => readStoredVersion(
498
+ retainedStoredVersion(parent) ?? ownValue(parent, 'version'),
499
+ { typeName: info?.parentType?.name ?? typeName, id: ownValue(parent, '_id') ?? ownValue(parent, 'id') },
500
+ )));
501
+ };
502
+
434
503
  const cloneInput = (value, type) => {
435
504
  if (value === null || value === undefined || !type) return value;
436
505
  if (type instanceof GraphQLNonNull) return cloneInput(value, type.ofType);
@@ -479,6 +548,9 @@ export const createRuntime = (adapter) => {
479
548
  operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness,
480
549
  } = resolveQueryFeatures(adapter.queryFeatures);
481
550
  const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
551
+ // The write features the adapter implements, read once: with optimisticConcurrency, schemas may
552
+ // contain protected types (#98).
553
+ const writeFeatures = resolveWriteFeatures(adapter.writeFeatures);
482
554
 
483
555
  const stateValue = adapter.stateValue || ((state) => state.name);
484
556
  const runtimeIdentity = {};
@@ -494,7 +566,11 @@ export const createRuntime = (adapter) => {
494
566
  const selfCollectionNameHolders = new Map();
495
567
  const qualifiedSelfCollections = new Map();
496
568
  const registeredMutations = {};
569
+ // Models createModel() returned for this runtime, as opposed to models supplied to connect(). A type
570
+ // with one was built for storage without a version field, even by a createSchema() that failed later.
571
+ const generatedModels = new WeakSet();
497
572
  const registeredQueries = {};
573
+ const registeredViews = {};
498
574
  const middlewares = [];
499
575
 
500
576
  const operations = {
@@ -555,9 +631,9 @@ export const createRuntime = (adapter) => {
555
631
  }
556
632
  };
557
633
 
558
- // Object and interface types a schema of this runtime can reach from its registered types and
559
- // custom operation results, following the same references as GraphQLSchema.
560
- const collectSchemaOutputTypes = () => {
634
+ // Object and interface types reachable from these roots, following the output references of
635
+ // GraphQLSchema: fields, interfaces and union members. Input objects cannot hold either.
636
+ const collectOutputTypes = (roots) => {
561
637
  const visited = new Set();
562
638
  const outputTypes = [];
563
639
  const visit = (type) => {
@@ -573,14 +649,20 @@ export const createRuntime = (adapter) => {
573
649
  named.getInterfaces().forEach(visit);
574
650
  for (const field of Object.values(named.getFields())) visit(field.type);
575
651
  };
652
+ roots.forEach(visit);
653
+ return outputTypes;
654
+ };
655
+
656
+ // Object and interface types a schema of this runtime can reach from its registered types and
657
+ // custom operation results, following the same references as GraphQLSchema.
658
+ const collectSchemaOutputTypes = () => {
576
659
  const registeredTypes = Object.values(typesDict.types).map(({ gqltype }) => gqltype);
577
660
  const customOperations = [
578
661
  ...Object.values(registeredMutations),
579
662
  ...Object.values(registeredQueries),
580
663
  ];
581
664
  const customOutputTypes = customOperations.map(({ outputModel }) => outputModel);
582
- [...registeredTypes, ...customOutputTypes].forEach(visit);
583
- return outputTypes;
665
+ return collectOutputTypes([...registeredTypes, ...customOutputTypes]);
584
666
  };
585
667
 
586
668
  const hasUnresolvedRelation = (gqltype) => Object.values(gqltype.getFields()).some(
@@ -813,8 +895,10 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
813
895
  + `\`${operation}\` operation in generated inputs, ${omittedOperation[operation]()}`);
814
896
  }
815
897
  }
816
- // Null IDs are skipped, so `deleted` accepts them whatever the collection's item nullability.
817
- fields.deleted = { type: new GraphQLList(GraphQLID) };
898
+ // Null entries are skipped, so `deleted` accepts them whatever the collection's item nullability.
899
+ // A protected item is deleted at the version the client read, whether or not the parent is
900
+ // protected (#98).
901
+ fields.deleted = { type: new GraphQLList(isProtected(itemType) ? RecordVersionRef : GraphQLID) };
818
902
  return fields;
819
903
  },
820
904
  });
@@ -1049,6 +1133,14 @@ const buildInputType = (gqltype, reachable) => {
1049
1133
  description: fieldEntry.description,
1050
1134
  };
1051
1135
  }
1136
+ // The version field is readOnly, so only this adds it, last and required, to a protected type's
1137
+ // update input, which updates, state actions and nested `updated` entries take (#98). Create inputs,
1138
+ // and the `added` items built from them, never get it. An update input that would hold only the
1139
+ // version, with no writable id or other field, is left without fields as before (#161), so the type
1140
+ // has no update mutation, state actions or nested `updated` entries, and keeps its delete mutation.
1141
+ if (isProtected(gqltype) && Object.keys(fieldsArgForUpdate).length > 0) {
1142
+ fieldsArgForUpdate.version = { type: new GraphQLNonNull(RecordVersion), description: argTypes.version.description };
1143
+ }
1052
1144
 
1053
1145
  const inputTypeForAdd = new GraphQLInputObjectType({
1054
1146
  name: `${gqltype.name}Input`,
@@ -1221,8 +1313,9 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
1221
1313
  const collectionFields = {};
1222
1314
 
1223
1315
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
1224
- // A virtual field stores nothing, whatever middleware or saveObject() passes for it.
1225
- if (isVirtualField(fieldEntry)) continue;
1316
+ // A virtual field stores nothing, whatever middleware or saveObject() passes for it, and Simfinity
1317
+ // writes the version field of a protected type itself (#98).
1318
+ if (isVirtualField(fieldEntry) || isVersionField(fieldEntry)) continue;
1226
1319
  const fieldValue = ownValue(args, fieldEntryName);
1227
1320
  if (fieldEntry.extensions && fieldEntry.extensions.validations
1228
1321
  && fieldEntry.extensions.validations[operation]) {
@@ -1338,10 +1431,58 @@ const iterateOnCollectionFields = async (materializedModel, gqltype, objectId, s
1338
1431
  const readByIdIsInert = (Model) => typeof adapter.readByIdIsInert === 'function'
1339
1432
  && adapter.readByIdIsInert(Model) === true;
1340
1433
 
1434
+ // Optimistic concurrency (#98). Every update, state action, delete and nested entry of a protected
1435
+ // type first reads its record whole, plain and locked: the pre-read, which also serves the embedded
1436
+ // merge, the from-state check and onDelete. Its version is normalized before any hook sees it, so a
1437
+ // MongoDB document stored before the type was protected shows 1, as on PostgreSQL.
1438
+ const normalizeProtectedVersion = (record, gqltype, id) => {
1439
+ record.version = readStoredVersion(record.version,
1440
+ { typeName: gqltype.name, id: ownValue(record, '_id') ?? ownValue(record, 'id') ?? id });
1441
+ return record.version;
1442
+ };
1443
+
1444
+ const readProtectedRecord = async (Model, gqltype, id, session) => {
1445
+ const record = await adapter.getById(Model, id, session, { plain: true, lock: true });
1446
+ if (record) normalizeProtectedVersion(record, gqltype, id);
1447
+ return record;
1448
+ };
1449
+
1450
+ // Whether a stale or exhausted version that the pre-read found is reported at once, before the
1451
+ // record's validators, hooks and action run (decision 9): to every caller of a type without a
1452
+ // get_by_id scope, and to a caller who can read the record through that scope. The record is read as
1453
+ // a by-ID read does, in the operation's session and by its stored key, not the client's spelling of
1454
+ // the ID. A scope function that throws counts as hiding the record; errors of the gates and of
1455
+ // adapter.find propagate.
1456
+ const reportEarlyConflict = async (type, record, context, session) => {
1457
+ if (!getScope(type.gqltype, 'get_by_id')) return true;
1458
+ const key = String(record._id ?? record.id);
1459
+ const args = { id: { operator: 'EQ', value: key } };
1460
+ try {
1461
+ return await findVisibleById(type, args, session, context, key) !== null;
1462
+ } catch (error) {
1463
+ if (failedByIdScopes.has(args)) return false;
1464
+ throw error;
1465
+ }
1466
+ };
1467
+
1468
+ // Outcome 3 of the pre-read: the stored version is not the expected one, or an update or a state
1469
+ // action (`advances`) would take it past MAX_RECORD_VERSION; a delete does not advance it. When the
1470
+ // conflict is not reported early, the operation continues exactly as with a matching version, and the
1471
+ // conditional write reports it.
1472
+ const assertRecordVersion = async (gqltype, id, record, expectedVersion, advances, context, session) => {
1473
+ let error = null;
1474
+ if (record.version !== expectedVersion) error = versionConflictError(gqltype.name, id);
1475
+ else if (advances && expectedVersion === MAX_RECORD_VERSION) error = versionExhaustedError(gqltype.name, id);
1476
+ if (error && await reportEarlyConflict(typesDict.types[gqltype.name], record, context, session)) throw error;
1477
+ };
1478
+
1341
1479
  // The record is read for the onDelete hook; adapter.delete casts the ID, deletes and returns the
1342
1480
  // record itself. Without a hook, the read is skipped when it is inert. A nested delete passes the
1343
- // child it has already read as `current`.
1344
- const onDelete = async (Model, controller, id, session, context, current) => {
1481
+ // child it has already read as `current`, and a protected delete its pre-read record, null when it is
1482
+ // missing, with `precondition`: the type and the version the record must still have. The adapter then
1483
+ // deletes it only at that version, and a record it deleted at another one, which an adapter wrapper
1484
+ // that drops the options would delete, fails with DATABASE_ERROR and rolls back with the transaction.
1485
+ const onDelete = async (Model, controller, id, session, context, current, precondition = null) => {
1345
1486
  const hasHook = !!(controller && controller.onDelete);
1346
1487
  let currentObject = current;
1347
1488
  if (currentObject === undefined && (hasHook || !readByIdIsInert(Model))) {
@@ -1352,7 +1493,13 @@ const onDelete = async (Model, controller, id, session, context, current) => {
1352
1493
  await controller.onDelete(currentObject, session, context);
1353
1494
  }
1354
1495
 
1355
- return adapter.delete(Model, id, session);
1496
+ if (!precondition) return adapter.delete(Model, id, session);
1497
+ const { gqltype, expectedVersion } = precondition;
1498
+ const deleted = await adapter.delete(Model, id, session, { expectedVersion });
1499
+ if (deleted && readStoredVersion(deleted.version, { typeName: gqltype.name, id }) !== expectedVersion) {
1500
+ throw unexpectedDeleteError(gqltype.name, id);
1501
+ }
1502
+ return deleted;
1356
1503
  };
1357
1504
 
1358
1505
  const getEmbeddedFieldNames = (gqltype) => {
@@ -1427,7 +1574,7 @@ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
1427
1574
  // onUpdating, so that a malformed ID fails before the hook, and before nested collection items, so
1428
1575
  // that the SQL adapter locks the parent row before child writes; otherwise it skips the read only
1429
1576
  // when it is inert. A state action or a nested update has already read and checked the record
1430
- // (`recordChecked`).
1577
+ // (`recordChecked`). A protected type never reads here: its pre-read record serves the merge (#98).
1431
1578
  const readsStoredEmbedded = (Model, controller, materializedModel, embeddedFieldNames, recordChecked) => {
1432
1579
  const usesStored = embeddedFieldNames.some((name) => {
1433
1580
  const value = ownValue(materializedModel.modelArgs, name);
@@ -1441,17 +1588,29 @@ const readsStoredEmbedded = (Model, controller, materializedModel, embeddedField
1441
1588
  || !readByIdIsInert(Model);
1442
1589
  };
1443
1590
 
1591
+ // Whether an update sets the version field, at the top level or inside an operator such as $set or
1592
+ // $unset. The adapter writes the version of a protected type (#98).
1593
+ const setsVersion = (update) => [update, ...Object.entries(update)
1594
+ .filter(([key]) => key.startsWith('$')).map(([, value]) => value)]
1595
+ .some((target) => target !== null && typeof target === 'object' && Object.hasOwn(target, 'version'));
1596
+
1597
+ // The update of a protected type (#98) takes from its caller the version captured before the
1598
+ // pre-read, `expectedVersion`, the immutable observed version, and the pre-read record (null when
1599
+ // missing). It merges embedded values from that record and writes only at the expected version,
1600
+ // which the adapter advances; onUpdating
1601
+ // cannot set the version, and a result that did not advance it fails with DATABASE_ERROR.
1444
1602
  const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context,
1445
- recordChecked = false) => {
1603
+ recordChecked = false, { expectedVersion, observedVersion, record } = {}) => {
1604
+ const versioned = isProtected(gqltype);
1446
1605
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
1447
1606
  const objectId = args.id;
1448
1607
  const argTypes = gqltype.getFields();
1449
1608
  const embeddedFieldNames = getEmbeddedFieldNames(gqltype);
1450
1609
 
1451
1610
  if (embeddedFieldNames.length > 0
1452
- && readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked)) {
1611
+ && (versioned || readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked))) {
1453
1612
  const projection = Object.fromEntries(embeddedFieldNames.map((name) => [name, 1]));
1454
- const currentObject = await adapter.getById(Model, objectId, session, {
1613
+ const currentObject = versioned ? record : await adapter.getById(Model, objectId, session, {
1455
1614
  projection,
1456
1615
  plain: true,
1457
1616
  lock: true,
@@ -1476,7 +1635,8 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1476
1635
  }
1477
1636
 
1478
1637
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
1479
- if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)) {
1638
+ if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)
1639
+ && !isVersionField(fieldEntry)) {
1480
1640
  materializedModel.modelArgs.$unset = {
1481
1641
  ...materializedModel.modelArgs.$unset,
1482
1642
  [getFieldStorageName(fieldEntryName, fieldEntry)]: '',
@@ -1491,11 +1651,24 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1491
1651
 
1492
1652
  if (controller && controller.onUpdating) {
1493
1653
  await controller.onUpdating(objectId, update, session, context);
1654
+ if (versioned && setsVersion(update)) throw versionManagedError(gqltype.name, 'onUpdating');
1494
1655
  }
1495
1656
 
1496
1657
  if (linkToParent) linkToParent(update);
1497
- const result = await adapter.update(Model, objectId, update, session);
1658
+ const result = versioned
1659
+ ? await adapter.update(Model, objectId, update, session, { expectedVersion })
1660
+ : await adapter.update(Model, objectId, update, session);
1498
1661
  if (!result) throw new SimfinityError(`${gqltype.name} ${objectId} is not valid`, 'NOT_VALID_ID', 404);
1662
+ // Diagnose a result that did not advance beyond the pre-read, including a dropped precondition
1663
+ // when the caller sent one revision less. This cannot prove arbitrary wrappers honored the atomic
1664
+ // condition: they must still forward it. The observation is captured before application code runs.
1665
+ if (versioned) {
1666
+ const returnedVersion = readStoredVersion(result.version, { typeName: gqltype.name, id: objectId });
1667
+ if (returnedVersion !== expectedVersion + 1
1668
+ || (observedVersion !== undefined && returnedVersion <= observedVersion)) {
1669
+ throw unadvancedVersionError(gqltype.name, objectId);
1670
+ }
1671
+ }
1499
1672
 
1500
1673
  if (materializedModel.collectionFields) {
1501
1674
  // Children link to, and are owned through, the parent's stored key, as when a new parent is
@@ -1512,18 +1685,27 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1512
1685
  return result;
1513
1686
  };
1514
1687
 
1688
+ // A protected type's version is captured before the action can change `args`, and checked on the
1689
+ // plain pre-read record before the from-state check, which reads `state` from that record (#98).
1515
1690
  const onStateChanged = async (Model, gqltype, controller, args, session, actionField, context) => {
1516
- const storedModel = await adapter.getById(Model, args.id, session, { lock: true });
1691
+ const versioned = isProtected(gqltype);
1692
+ const expectedVersion = versioned ? assertVersionInput(args.version) : undefined;
1693
+ const storedModel = versioned
1694
+ ? await readProtectedRecord(Model, gqltype, args.id, session)
1695
+ : await adapter.getById(Model, args.id, session, { lock: true });
1696
+ const observedVersion = versioned ? storedModel?.version : undefined;
1517
1697
  if (!storedModel) {
1518
1698
  throw new SimfinityError(`${gqltype.name} ${args.id} is not valid`, 'NOT_VALID_ID', 404);
1519
1699
  }
1700
+ if (versioned) await assertRecordVersion(gqltype, args.id, storedModel, expectedVersion, true, context, session);
1520
1701
  if (storedModel.state === stateValue(actionField.from)) {
1521
1702
  if (actionField.action) {
1522
1703
  await actionField.action(args, session);
1523
1704
  }
1524
1705
 
1525
1706
  args.state = stateValue(actionField.to);
1526
- let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context, true);
1707
+ let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context, true,
1708
+ versioned ? { expectedVersion, observedVersion, record: storedModel } : undefined);
1527
1709
  result = adapter.toObject(result);
1528
1710
  result.state = actionField.to.value;
1529
1711
  return result;
@@ -1538,11 +1720,15 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
1538
1720
  typesDict.types[gqltype.name].stateMachine.initialState,
1539
1721
  );
1540
1722
  }
1723
+ // A protected record starts at version 1, whatever saveObject() or middleware supplied (#98).
1724
+ const versioned = isProtected(gqltype);
1725
+ if (versioned) materializedModel.modelArgs.version = 1;
1541
1726
 
1542
1727
  const newObject = adapter.newRecord(Model, materializedModel.modelArgs, session);
1543
1728
 
1544
1729
  if (controller && controller.onSaving) {
1545
1730
  await controller.onSaving(newObject, args, session, context);
1731
+ if (versioned && newObject.version !== 1) throw versionManagedError(gqltype.name, 'onSaving');
1546
1732
  }
1547
1733
 
1548
1734
  if (linkToParent) linkToParent(newObject);
@@ -1634,12 +1820,30 @@ const assertCollectionWrites = (gqltype, input) => {
1634
1820
  }
1635
1821
  };
1636
1822
 
1823
+ // The capture and the pre-read of a protected top-level update or delete (#98): the version the
1824
+ // client sent is checked before anything is read, then against the pre-read record. A missing record
1825
+ // is not reported here; the update fails at its write with NOT_VALID_ID and the delete returns null,
1826
+ // as for an unprotected type.
1827
+ const preReadTopLevel = async (Model, gqltype, { id, version }, advances, context, session) => {
1828
+ const expectedVersion = assertVersionInput(version);
1829
+ const record = await readProtectedRecord(Model, gqltype, id, session);
1830
+ const observedVersion = record?.version;
1831
+ if (record) await assertRecordVersion(gqltype, id, record, expectedVersion, advances, context, session);
1832
+ return { expectedVersion, observedVersion, record };
1833
+ };
1834
+
1835
+ // A delete passes the ID, or { id, version } for a protected type, which each attempt copies; every
1836
+ // other operation passes its input, which attemptInputs copies. Either way, a retry checks the version
1837
+ // the request sent.
1637
1838
  const executeOperation = (Model, gqltype, controller, args, operation, actionField,
1638
1839
  session, context, inputType) => {
1639
1840
  const limit = getMaxNestedOperations();
1640
1841
  if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
1641
1842
  if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
1642
- const nextInput = operation === operations.DELETE ? () => args : attemptInputs(args, inputType);
1843
+ const versioned = isProtected(gqltype);
1844
+ let nextInput = () => args;
1845
+ if (operation !== operations.DELETE) nextInput = attemptInputs(args, inputType);
1846
+ else if (versioned) nextInput = () => ({ ...args });
1643
1847
  return adapter.withTransaction(
1644
1848
  session,
1645
1849
  async (mySession) => {
@@ -1647,10 +1851,17 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
1647
1851
  switch (operation) {
1648
1852
  case operations.SAVE:
1649
1853
  return onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
1650
- case operations.UPDATE:
1651
- return onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context);
1652
- case operations.DELETE:
1653
- return onDelete(Model, controller, attemptArgs, mySession, context);
1854
+ case operations.UPDATE: {
1855
+ const precondition = versioned
1856
+ ? await preReadTopLevel(Model, gqltype, attemptArgs, true, context, mySession) : undefined;
1857
+ return onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context, false, precondition);
1858
+ }
1859
+ case operations.DELETE: {
1860
+ if (!versioned) return onDelete(Model, controller, attemptArgs, mySession, context);
1861
+ const { expectedVersion, record } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
1862
+ mySession);
1863
+ return onDelete(Model, controller, attemptArgs.id, mySession, context, record, { gqltype, expectedVersion });
1864
+ }
1654
1865
  case operations.STATE_CHANGED:
1655
1866
  return onStateChanged(Model, gqltype, controller,
1656
1867
  attemptArgs, mySession, actionField, context);
@@ -1683,14 +1894,28 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1683
1894
  if (item.$set) delete item.$set[connection.storageFieldName];
1684
1895
  };
1685
1896
 
1897
+ // A protected child is updated or deleted at the version its entry carries (#98). A deleted entry is
1898
+ // { id, version } for item middleware; one that is not an object, such as a string ID, has no version.
1899
+ const versioned = isProtected(collectionGQLType) && operationType !== operations.SAVE;
1686
1900
  for (const element of collectionFieldsList) {
1687
1901
  if (element === null) continue;
1688
- const args = operationType === operations.DELETE ? { id: element } : { input: element };
1902
+ let args = { input: element };
1903
+ if (operationType === operations.DELETE) {
1904
+ args = versioned ? { id: element?.id, version: element?.version } : { id: element };
1905
+ }
1689
1906
  await executeMiddleware({ type, args, operation: operationType, context });
1690
1907
 
1908
+ // Captured after the item's middleware, before the child is read.
1909
+ let expectedVersion;
1910
+ if (versioned) {
1911
+ expectedVersion = assertVersionInput(operationType === operations.DELETE ? args.version : args.input.version);
1912
+ }
1913
+
1691
1914
  // The ownership check reads the child (the SQL adapter locks its row). onDelete receives this
1692
- // record, and an update reads the child again only to merge embedded values.
1915
+ // record, and an update reads the child again only to merge embedded values, unless the child is
1916
+ // protected: then this is its pre-read, whose version is checked after ownership.
1693
1917
  let child;
1918
+ let observedVersion;
1694
1919
  if (operationType === operations.UPDATE || operationType === operations.DELETE) {
1695
1920
  const id = operationType === operations.DELETE ? args.id : args.input.id;
1696
1921
  child = await adapter.getById(type.model, id, session, { plain: true, lock: true });
@@ -1700,6 +1925,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1700
1925
  if (String(child[connection.storageFieldName]) !== String(parentKey)) {
1701
1926
  throw new ForbiddenError('Child does not belong to this parent');
1702
1927
  }
1928
+ if (versioned) {
1929
+ observedVersion = normalizeProtectedVersion(child, collectionGQLType, id);
1930
+ await assertRecordVersion(collectionGQLType, id, child, expectedVersion, operationType === operations.UPDATE,
1931
+ context, session);
1932
+ }
1703
1933
  }
1704
1934
 
1705
1935
  switch (operationType) {
@@ -1709,10 +1939,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1709
1939
  break;
1710
1940
  case operations.UPDATE:
1711
1941
  await onUpdateSubject(type.model, collectionGQLType, type.controller,
1712
- args.input, session, linkToParent, context, true);
1942
+ args.input, session, linkToParent, context, true, versioned ? { expectedVersion, observedVersion, record: child } : undefined);
1713
1943
  break;
1714
1944
  case operations.DELETE:
1715
- await onDelete(type.model, type.controller, args.id, session, context, child);
1945
+ await onDelete(type.model, type.controller, args.id, session, context, child,
1946
+ versioned ? { gqltype: collectionGQLType, expectedVersion } : null);
1716
1947
  }
1717
1948
  }
1718
1949
  };
@@ -1813,7 +2044,9 @@ const copyClientFilters = (args, operation) => {
1813
2044
  // scope. When the adapter compiles reference emptiness (#108 part 2b), a client's emptiness term or
1814
2045
  // condition reads its references and collections through scoped joins and frames instead, so it
1815
2046
  // enters no joined scope either; every other entry, beside it or on the same path, records its own.
1816
- const inspectClientPaths = (gqltype, args, operation) => {
2047
+ // Without `checkQueryable`, for a view's `where`, whose paths are the author's as a scope's are, a
2048
+ // non-queryable field is not rejected; a virtual one still is.
2049
+ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true } = {}) => {
1817
2050
  copyClientFilters(args, operation);
1818
2051
  const joinedScopes = new Map();
1819
2052
  const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
@@ -1827,7 +2060,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
1827
2060
  throw forbiddenPath(`Query path ${path} uses the virtual field ${type.name}.${fieldName}, which is not stored`);
1828
2061
  }
1829
2062
  const queryable = field.extensions?.queryable;
1830
- if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
2063
+ if (checkQueryable && (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName)))) {
1831
2064
  throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
1832
2065
  }
1833
2066
  if (!joins) return;
@@ -2069,31 +2302,34 @@ const hasScopeMarker = (construct) => construct != null && Object.hasOwn(constru
2069
2302
 
2070
2303
  // Marks the client's constructs that read references or collections, from the gate's conditions on the
2071
2304
  // client's arguments, then applies maxReferenceFrames to the distinct (kind, path) pairs of their
2072
- // scoped joins and frames in this call, so every 2a code comes first. Returns the issued markers, as
2073
- // id -> the client's operator and path, or null when the adapter does not compile reference
2074
- // emptiness. Ids are ordinals in the order of the conditions, so identical arguments get identical
2075
- // markers and sibling collection reads keep one batch key.
2076
- const markClientConstructs = (checked) => {
2305
+ // scoped joins and frames in these conditions, so every 2a code comes first. Returns the issued
2306
+ // markers, as id -> the client's operator and path, or null when the adapter does not compile
2307
+ // reference emptiness. Ids are ordinals in the order of the conditions, so identical arguments get
2308
+ // identical markers and sibling collection reads keep one batch key. With `issued`, the markers of
2309
+ // the call so far, the ids continue on that map, so that a view's `where`, marked after the client's
2310
+ // arguments, shares them; the limit still applies to each set of conditions on its own.
2311
+ const markClientConstructs = (checked, issued) => {
2077
2312
  if (!supportsReferenceEmptiness) return null;
2078
- const issued = new Map();
2313
+ const markers = issued ?? new Map();
2079
2314
  const pairs = new Set();
2080
2315
  for (const { entry, shape } of checked) {
2081
2316
  const { construct } = entry;
2082
2317
  if (!shape || !construct || hasScopeMarker(construct)) continue;
2083
2318
  const { joins, frames } = emptinessScopePaths(shape, entry.segments);
2084
2319
  if (!joins.length && !frames.length) continue;
2085
- const id = issued.size;
2320
+ const id = markers.size;
2086
2321
  construct[SCOPE_MARKER] = { id };
2087
- issued.set(id, { operator: entry.operator, path: entry.path });
2322
+ markers.set(id, { operator: entry.operator, path: entry.path });
2088
2323
  for (const path of joins) pairs.add(`join ${path}`);
2089
2324
  for (const path of frames) pairs.add(`frame ${path}`);
2090
2325
  }
2091
2326
  const limit = getQueryMaxReferenceFrames();
2092
2327
  if (pairs.size > limit) {
2093
2328
  throw new SimfinityError(`Emptiness conditions through references and collections need ${pairs.size} scoped joins and `
2094
- + `frames in one query field, more than the limit of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
2329
+ + 'frames in one set of conditions (the client\'s arguments of a query field, or a view\'s where), more than the limit '
2330
+ + `of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
2095
2331
  }
2096
- return issued;
2332
+ return markers;
2097
2333
  };
2098
2334
 
2099
2335
  // The detach check: every marker issued in this call sits, well formed (an object whose only own key
@@ -2298,6 +2534,29 @@ const loadReference = (type, id, requiredId, context) => {
2298
2534
  });
2299
2535
  };
2300
2536
 
2537
+ // The query arguments whose get_by_id scope function threw in findVisibleById. The error propagates
2538
+ // as it was thrown; the conflict path (reportEarlyConflict) tells it from errors of the gates and of
2539
+ // adapter.find this way. Each caller (resolveById and reportEarlyConflict) allocates fresh args
2540
+ // and calls findVisibleById exactly once with that object; never reuse marked args for another read.
2541
+ const failedByIdScopes = new WeakSet();
2542
+
2543
+ // The record that the type's get_by_id scope shows the caller, or null. `args` holds an `id` EQ
2544
+ // condition, to which the scope adds its own; the gates check them as for every read, and adapter.find
2545
+ // reads in `session`, pinned to `requiredId`. get_by_id middleware is not run here; resolveById runs it
2546
+ // first.
2547
+ const findVisibleById = async (type, args, session, context, requiredId) => {
2548
+ try {
2549
+ await executeScope({ type, args, operation: 'get_by_id', context });
2550
+ } catch (error) {
2551
+ failedByIdScopes.add(args);
2552
+ throw error;
2553
+ }
2554
+ assertStoredQueryPaths(type.gqltype, args, 'find');
2555
+ assertQueryConditions(type.gqltype, args, 'find');
2556
+ const results = await adapter.find(type.model, type.gqltype, args, session, { requiredId, context });
2557
+ return results[0] || null;
2558
+ };
2559
+
2301
2560
  // `requiredId` marks a generated single-reference read. Middleware still runs per call; then, when the
2302
2561
  // adapter reads by ID in batches, and unless the type has a get_by_id scope or middleware changed the
2303
2562
  // ID, the read joins the request's batch.
@@ -2311,12 +2570,7 @@ const resolveById = async (type, args, context, requiredId) => {
2311
2570
  }
2312
2571
  return adapter.getById(type.model, args.id, null, { requiredId, context });
2313
2572
  }
2314
- const queryArgs = { id: { operator: 'EQ', value: args.id } };
2315
- await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
2316
- assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
2317
- assertQueryConditions(type.gqltype, queryArgs, 'find');
2318
- const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
2319
- return results[0] || null;
2573
+ return findVisibleById(type, { id: { operator: 'EQ', value: args.id } }, null, context, requiredId);
2320
2574
  };
2321
2575
 
2322
2576
  // Batched collection reads. A generated collection field reads its children with one
@@ -2707,10 +2961,13 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2707
2961
  : `mutation that takes no input or an input of its own, since getInputType(${type.gqltype.name}) `
2708
2962
  + 'has no fields.'));
2709
2963
  }
2964
+ // A protected type is deleted at the version the client read (#98).
2965
+ const deleteArgs = { id: { type: new GraphQLNonNull(GraphQLID) } };
2966
+ if (isProtected(type.gqltype)) deleteArgs.version = { type: new GraphQLNonNull(RecordVersion) };
2710
2967
  rootQueryArgs.fields[`delete${type.simpleEntityEndpointName}`] = {
2711
2968
  type: type.gqltype,
2712
2969
  description: 'delete',
2713
- args: { id: { type: new GraphQLNonNull(GraphQLID) } },
2970
+ args: deleteArgs,
2714
2971
  extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.DELETE } },
2715
2972
  async resolve(parent, args, context) {
2716
2973
  const params = {
@@ -2721,8 +2978,10 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2721
2978
  };
2722
2979
 
2723
2980
  await executeMiddleware(params);
2981
+ // A protected delete takes the version as middleware left it.
2982
+ const target = isProtected(type.gqltype) ? { id: args.id, version: args.version } : args.id;
2724
2983
  return executeOperation(type.model, type.gqltype, type.controller,
2725
- args.id, operations.DELETE, null, null, context, null);
2984
+ target, operations.DELETE, null, null, context, null);
2726
2985
  },
2727
2986
  };
2728
2987
  }
@@ -2805,6 +3064,170 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2805
3064
  return new GraphQLObjectType(rootQueryArgs);
2806
3065
  };
2807
3066
 
3067
+ // The prefix every generated read shares, for the list and aggregate roots, the collection fields and
3068
+ // the views: the client's paths and conditions are inspected and marked, middleware runs, with `entry`
3069
+ // when one is given, `extend` adds the caller's own constructs and returns their joined scopes, the
3070
+ // type's scope runs, the client's joined scopes and then the extension's are applied, and the final
3071
+ // arguments are settled. Returns the params, whose args the adapter reads. An empty map of the
3072
+ // client's joined scopes is not awaited, so that, with the caller's await of this function, a read
3073
+ // without one still reaches the adapter after as many promise jobs as before it was shared.
3074
+ const prepareRead = async (type, args, context, operation, { entry, extend } = {}) => {
3075
+ const joinedScopes = inspectClientPaths(type.gqltype, args, operation);
3076
+ const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation));
3077
+ const params = entry === undefined
3078
+ ? { type, args, operation, context }
3079
+ : { type, args, operation, entry, context };
3080
+ await executeMiddleware(params);
3081
+ const extended = extend ? await extend(params, issued) : null;
3082
+ await executeScope(params);
3083
+ if (joinedScopes.size) await applyJoinedScopes(joinedScopes, params);
3084
+ if (extended) await applyJoinedScopes(extended, params);
3085
+ const filling = settleClientArgs(type.gqltype, params, operation, issued);
3086
+ if (filling) await filling;
3087
+ return params;
3088
+ };
3089
+
3090
+ // The tail of a list read, shared by the generated list root and the views: the rows and, when the
3091
+ // client asks for pagination.count and something can receive it, the count, reported through the sink.
3092
+ const readList = async (type, params, parent, info, context) => {
3093
+ const queryArgs = params.args;
3094
+ const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
3095
+ && canReportCount(parent, info, context);
3096
+
3097
+ const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
3098
+ const countPromise = wantsCount
3099
+ ? adapter.count(type.model, type.gqltype, queryArgs, null)
3100
+ : null;
3101
+
3102
+ const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
3103
+ if (wantsCount && !reportCount(parent, info, context, resultCount)) {
3104
+ warnOnce(runtimeIdentity, 'count-context', 'Configuration issue: the GraphQL context does not accept '
3105
+ + 'a count property (it is frozen, sealed or has a read-only count), so pagination counts are not '
3106
+ + 'reported; pass a fresh mutable context object for each request.');
3107
+ }
3108
+ return result;
3109
+ };
3110
+
3111
+ // Separates a view's params, named by `paramNames`, from the rest of its arguments, which are exactly
3112
+ // a generated list's.
3113
+ const splitViewArgs = (paramNames, args) => {
3114
+ const params = {};
3115
+ const rest = {};
3116
+ for (const [name, value] of Object.entries(args)) {
3117
+ if (paramNames.has(name)) params[name] = value;
3118
+ else rest[name] = value;
3119
+ }
3120
+ return { params, args: rest };
3121
+ };
3122
+
3123
+ const invalidWhere = (view, detail) => new SimfinityError(`View ${view.name} where ${detail}`, 'INVALID_VIEW', 500);
3124
+
3125
+ // The groups a view's `where` returned are plain objects whose `conditions`, `AND` and `OR`, when set,
3126
+ // are arrays: of plain objects with a field name, and of groups like these. collectQueryPathEntries
3127
+ // skips anything else silently, so this comes before their paths are read. Only the GraphQL input
3128
+ // fields are structural keys; condition values stay opaque. A condition never carries a client scope
3129
+ // marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
3130
+ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
3131
+ const groupFields = QLFilterGroup.getFields();
3132
+ const conditionFields = QLFilterCondition.getFields();
3133
+ for (const group of groups) {
3134
+ if (!isNonNullPlainObject(group)) throw invalidWhere(view, 'returned a group that is not a plain object');
3135
+ if (ancestors.has(group)) throw invalidWhere(view, 'returned a cyclic group');
3136
+ for (const key of Object.keys(group)) {
3137
+ if (!Object.hasOwn(groupFields, key)) throw invalidWhere(view, `returned a group with unknown key ${key}`);
3138
+ }
3139
+ for (const key of Object.keys(groupFields)) {
3140
+ if (group[key] != null && !Array.isArray(group[key])) throw invalidWhere(view, `returned a group whose ${key} is not an array`);
3141
+ }
3142
+ for (const condition of group.conditions ?? []) {
3143
+ if (!isNonNullPlainObject(condition) || typeof condition.field !== 'string' || condition.field === '') {
3144
+ throw invalidWhere(view, 'returned a condition that is not a plain object with a field name');
3145
+ }
3146
+ if (hasScopeMarker(condition)) {
3147
+ throw invalidWhere(view, `condition on ${condition.field} carries a ${SCOPE_MARKER} key, which Simfinity reserves`);
3148
+ }
3149
+ for (const key of Object.keys(condition)) {
3150
+ if (!Object.hasOwn(conditionFields, key)) throw invalidWhere(view, `condition on ${condition.field} has unknown key ${key}`);
3151
+ }
3152
+ }
3153
+ ancestors.add(group);
3154
+ assertViewGroupShapes(view, [...(group.AND ?? []), ...(group.OR ?? [])], ancestors);
3155
+ ancestors.delete(group);
3156
+ }
3157
+ };
3158
+
3159
+ // A `where` that does not return an array of groups, or names a field its type does not have, a path
3160
+ // past a field without fields, or a virtual field, is the author's bug: INVALID_VIEW (500), never the
3161
+ // FORBIDDEN_FILTER_PATH (403) that inspectClientPaths gives a client for a virtual field. Each path is
3162
+ // walked here, since walkQueryPath stops silently at an unknown segment.
3163
+ const assertViewGroups = (view, groups) => {
3164
+ if (!Array.isArray(groups)) throw invalidWhere(view, 'must return an array of AND groups');
3165
+ assertViewGroupShapes(view, groups);
3166
+ for (const { segments } of collectQueryPathEntries({ AND: groups }, 'find', { gqltype: view.type })) {
3167
+ let type = view.type;
3168
+ let parent = null;
3169
+ for (const [index, segment] of segments.entries()) {
3170
+ const names = (detail) => invalidWhere(view, `names ${segments.slice(0, index + 1).join('.')}, which is ${detail}`);
3171
+ if (typeof type.getFields !== 'function') throw names(`past ${parent}, whose type ${type.name} has no fields`);
3172
+ const fields = type.getFields();
3173
+ if (!Object.hasOwn(fields, segment)) throw names(`not a field of ${type.name}`);
3174
+ if (isVirtualField(fields[segment])) throw names(`the virtual field ${type.name}.${segment}`);
3175
+ parent = `${type.name}.${segment}`;
3176
+ type = unwrapListAndNonNull(fields[segment].type);
3177
+ }
3178
+ }
3179
+ };
3180
+
3181
+ // The `extend` of a view's read (see prepareRead). After middleware, the groups of `where` are read as
3182
+ // the client's constructs on a probe, whose AND holds copies of them (inspectClientPaths), so the
3183
+ // author's objects are never touched: their paths are inspected, their conditions checked and marked,
3184
+ // with ids that continue the client's, then the copies are appended to the top-level AND, where no
3185
+ // middleware can drop them. Returns their joined scopes, which attach to the appended copies. Their
3186
+ // paths are the author's, as a scope's are, so a non-queryable field is not rejected.
3187
+ const viewExtension = (view, viewParams) => async (params, issued) => {
3188
+ const groups = await view.where({ params: viewParams, context: params.context });
3189
+ assertViewGroups(view, groups);
3190
+ const probe = { AND: groups };
3191
+ const scopes = inspectClientPaths(view.type, probe, 'find', { checkQueryable: false });
3192
+ const checked = assertQueryConditions(view.type, probe, 'find');
3193
+ markClientConstructs(checked, issued);
3194
+ appendAndGroups(params.args, probe.AND);
3195
+ return scopes;
3196
+ };
3197
+
3198
+ // A view's root field: the generated list of its type, with the view's params before the generated
3199
+ // arguments. Each call reads the type's registration, whose model createSchema prepares, strips the
3200
+ // params, by the names the field was built with, before the list pipeline runs, and has the pipeline
3201
+ // append the view's `where` after middleware (viewExtension).
3202
+ const buildViewField = (view) => {
3203
+ const paramArgs = {};
3204
+ for (const [paramName, param] of Object.entries(view.params)) {
3205
+ const arg = { type: param.type };
3206
+ if (Object.hasOwn(param, 'description')) arg.description = param.description;
3207
+ if (Object.hasOwn(param, 'defaultValue')) arg.defaultValue = param.defaultValue;
3208
+ paramArgs[paramName] = arg;
3209
+ }
3210
+ const paramNames = new Set(Object.keys(paramArgs));
3211
+ return {
3212
+ type: new GraphQLList(view.type),
3213
+ description: view.description,
3214
+ args: { ...paramArgs, ...createArgsForQuery(view.type) },
3215
+ extensions: {
3216
+ simfinityQuery: {
3217
+ typeName: view.type.name, operation: 'find', countSink: true, view: { name: view.name },
3218
+ },
3219
+ },
3220
+ async resolve(parent, args, context, info) {
3221
+ const registration = typesDict.types[view.type.name];
3222
+ const split = splitViewArgs(paramNames, args);
3223
+ const params = await prepareRead(registration, split.args, context, 'find', {
3224
+ entry: view.name, extend: viewExtension(view, split.params),
3225
+ });
3226
+ return readList(registration, params, parent, info, context);
3227
+ },
3228
+ };
3229
+ };
3230
+
2808
3231
  const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult, includedCustomQueries = null) => {
2809
3232
  const rootQueryArgs = {};
2810
3233
  rootQueryArgs.name = name;
@@ -2831,35 +3254,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2831
3254
  // that supplies a root-value sink can pass its context unchanged.
2832
3255
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
2833
3256
  async resolve(parent, args, context, info) {
2834
- const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
2835
- const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'find'));
2836
- const params = {
2837
- type,
2838
- args,
2839
- operation: 'find',
2840
- context,
2841
- };
2842
- await executeMiddleware(params);
2843
- await executeScope(params);
2844
- await applyJoinedScopes(joinedScopes, params);
2845
- const filling = settleClientArgs(type.gqltype, params, 'find', issued);
2846
- if (filling) await filling;
2847
- const queryArgs = params.args;
2848
- const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
2849
- && canReportCount(parent, info, context);
2850
-
2851
- const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
2852
- const countPromise = wantsCount
2853
- ? adapter.count(type.model, type.gqltype, queryArgs, null)
2854
- : null;
2855
-
2856
- const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
2857
- if (wantsCount && !reportCount(parent, info, context, resultCount)) {
2858
- warnOnce(runtimeIdentity, 'count-context', 'Configuration issue: the GraphQL context does not accept '
2859
- + 'a count property (it is frozen, sealed or has a read-only count), so pagination counts are not '
2860
- + 'reported; pass a fresh mutable context object for each request.');
2861
- }
2862
- return result;
3257
+ const params = await prepareRead(type, args, context, 'find');
3258
+ return readList(type, params, parent, info, context);
2863
3259
  },
2864
3260
  };
2865
3261
 
@@ -2879,19 +3275,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2879
3275
  args: aggregateArgsObject,
2880
3276
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
2881
3277
  async resolve(parent, args, context) {
2882
- const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
2883
- const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'aggregate'));
2884
- const params = {
2885
- type,
2886
- args,
2887
- operation: 'aggregate',
2888
- context,
2889
- };
2890
- await executeMiddleware(params);
2891
- await executeScope(params);
2892
- await applyJoinedScopes(joinedScopes, params);
2893
- const filling = settleClientArgs(type.gqltype, params, 'aggregate', issued);
2894
- if (filling) await filling;
3278
+ const params = await prepareRead(type, args, context, 'aggregate');
2895
3279
  return adapter.aggregate(type.model, type.gqltype, params.args, null);
2896
3280
  },
2897
3281
  };
@@ -2906,6 +3290,13 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2906
3290
  }
2907
3291
  }
2908
3292
 
3293
+ // A view is in the schema exactly when its type is; assertViewsAvailable has checked its name.
3294
+ for (const view of Object.values(registeredViews)) {
3295
+ if (!shouldNotBeIncludedInSchema(includedTypes, view.type)) {
3296
+ rootQueryArgs.fields[view.name] = buildViewField(view);
3297
+ }
3298
+ }
3299
+
2909
3300
  return new GraphQLObjectType(rootQueryArgs);
2910
3301
  };
2911
3302
 
@@ -3179,6 +3570,108 @@ const warnUndeclaredVirtualFields = () => {
3179
3570
  }
3180
3571
  };
3181
3572
 
3573
+ // The types a model stores: the registered types and the embedded types they reach at any depth,
3574
+ // readOnly fields included, as assertPlainConnectionFields visits them.
3575
+ const collectStoredTypes = () => {
3576
+ const stored = new Set();
3577
+ const visit = (gqltype) => {
3578
+ if (stored.has(gqltype)) return;
3579
+ stored.add(gqltype);
3580
+ for (const field of Object.values(gqltype.getFields())) {
3581
+ const named = field.extensions?.relation?.embedded && getNamedType(field.type);
3582
+ if (named instanceof GraphQLObjectType) visit(named);
3583
+ }
3584
+ };
3585
+ for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
3586
+ return [...stored];
3587
+ };
3588
+
3589
+ const invalidModel = (message) => new SimfinityError(message, 'INVALID_MODEL', 400);
3590
+
3591
+ // Optimistic concurrency (#98), declared per type with `extensions.concurrency`. These checks run in
3592
+ // this order, after stored identities are known and before adapter.prepare(), so a rejected schema
3593
+ // changes nothing. `applicationTypes` are the roots of the schema's types. Returns the registrations of
3594
+ // the protected types and whether the included roots reserve the version names.
3595
+ const assertConcurrencyDeclarations = (applicationTypes) => {
3596
+ const registrations = Object.values(typesDict.types);
3597
+ // The shape of every declaration.
3598
+ const protectedTypes = registrations.filter(({ gqltype }) => concurrencyDeclared(gqltype));
3599
+ // Generated inputs, models and stored data follow the protection a type had when the first schema
3600
+ // containing it was created, and an injected field is never removed, so neither can change later.
3601
+ // Inputs or a model that an earlier createSchema() built without the field also fix protection,
3602
+ // even if the call failed afterwards; a model supplied to connect() does not.
3603
+ for (const registration of registrations) {
3604
+ const { gqltype } = registration;
3605
+ const declared = protectedTypes.includes(registration);
3606
+ const builtWithoutField = !isProtected(gqltype)
3607
+ && (!!registration.inputType || generatedModels.has(registration.model));
3608
+ if (declared && protectionStatus.get(gqltype) === false) {
3609
+ throw invalidModel(`${gqltype.name} declares optimistic concurrency after a schema was created without it; `
3610
+ + `declare it before the first createSchema() that contains ${gqltype.name}`);
3611
+ }
3612
+ if (declared && builtWithoutField) {
3613
+ throw invalidModel(`${gqltype.name} declares optimistic concurrency after its inputs or generated model were built without it; `
3614
+ + `declare it before the first createSchema() attempt that builds ${gqltype.name}`);
3615
+ }
3616
+ if (!declared && isProtected(gqltype)) {
3617
+ throw invalidModel(`${gqltype.name} no longer declares optimistic concurrency, but Simfinity already added its `
3618
+ + 'version field; keep extensions.concurrency on a type once it has that field');
3619
+ }
3620
+ }
3621
+ // Another runtime that registers a protected type injects its field when that runtime creates a
3622
+ // schema, which would change a schema already built here, so this schema must register every
3623
+ // protected type it contains, whatever the adapter supports.
3624
+ const reachedTypes = collectOutputTypes(applicationTypes);
3625
+ for (const type of reachedTypes) {
3626
+ if (type instanceof GraphQLObjectType && typesDict.types[type.name]?.gqltype !== type && isProtectedType(type)) {
3627
+ throw invalidModel(`${type.name} declares optimistic concurrency, so a schema that contains it must register it `
3628
+ + 'in this runtime, with connect() or as a relation target');
3629
+ }
3630
+ }
3631
+ // An embedded value's changes belong to the revision of the record that embeds it, wherever a model
3632
+ // stores it.
3633
+ const storedTypes = collectStoredTypes();
3634
+ const storedFields = storedTypes.flatMap((gqltype) => Object.entries(gqltype.getFields())
3635
+ .map(([fieldName, field]) => ({ gqltype, fieldName, field })));
3636
+ for (const { gqltype, fieldName, field } of storedFields) {
3637
+ const named = field.extensions?.relation?.embedded && getNamedType(field.type);
3638
+ if (named instanceof GraphQLObjectType && isProtectedType(named)) {
3639
+ throw invalidModel(versionEmbeddedMisuse(named.name, `${gqltype.name}.${fieldName}`));
3640
+ }
3641
+ }
3642
+ // Only an entity has a record of its own whose version can advance.
3643
+ const nonEntity = protectedTypes.find(({ storedIdentity }) => storedIdentity !== true);
3644
+ if (nonEntity) {
3645
+ throw invalidModel(`${nonEntity.gqltype.name} declares optimistic concurrency, so it must be an entity: register it `
3646
+ + 'with connect() or make it a relation target');
3647
+ }
3648
+ // Nothing else may be stored, or link records, under the version field's name.
3649
+ for (const { gqltype } of protectedTypes) {
3650
+ const collision = versionFieldCollision(gqltype);
3651
+ if (collision) throw invalidModel(collision);
3652
+ }
3653
+ for (const { gqltype, fieldName, field } of storedFields) {
3654
+ const collision = versionConnectionCollision(gqltype, fieldName, field);
3655
+ if (collision) throw invalidModel(collision);
3656
+ }
3657
+ // RecordVersion types managed version fields only, with or without protected types; registered
3658
+ // operations may still use it in their own input and output types.
3659
+ for (const gqltype of storedTypes) {
3660
+ const misuse = recordVersionFieldMisuse(gqltype);
3661
+ if (misuse) throw invalidModel(misuse);
3662
+ }
3663
+ const reserveVersionNames = protectedTypes.some(({ gqltype }) => reachedTypes.includes(gqltype));
3664
+ if (protectedTypes.length && !writeFeatures.optimisticConcurrency) {
3665
+ throw invalidModel(`The database adapter does not support optimistic concurrency, which ${protectedTypes[0].gqltype.name} `
3666
+ + 'declares (extensions.concurrency)');
3667
+ }
3668
+ // A clash that only generated types or the fields of an input object reach is found when the schema
3669
+ // is constructed.
3670
+ const clash = reserveVersionNames && versionTypeClash(applicationTypes, { inputFields: false });
3671
+ if (clash) throw reservedVersionTypeNameError(clash);
3672
+ return { protectedTypes, reserveVersionNames };
3673
+ };
3674
+
3182
3675
  const getRegistrations = () => Object.values(typesDict.types);
3183
3676
 
3184
3677
  const includedOperationTypes = (registrations, includedOperations) => Object.entries(registrations)
@@ -3186,7 +3679,8 @@ const includedOperationTypes = (registrations, includedOperations) => Object.ent
3186
3679
  .flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean));
3187
3680
 
3188
3681
  // Application types that a schema with these allowlists contains, whatever else it reaches: the
3189
- // endpoint types of its queries and mutations, and the input and output types of its custom operations.
3682
+ // endpoint types of its queries and mutations, the input and output types of its custom operations,
3683
+ // and the scalar/enum parameters of included named views.
3190
3684
  const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations, includedCustomQueries) => [
3191
3685
  ...Object.values(typesDict.types)
3192
3686
  .filter((type) => type.endpoint
@@ -3195,6 +3689,9 @@ const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, inc
3195
3689
  .map((type) => type.gqltype),
3196
3690
  ...includedOperationTypes(registeredMutations, includedCustomMutations),
3197
3691
  ...includedOperationTypes(registeredQueries, includedCustomQueries),
3692
+ ...Object.values(registeredViews)
3693
+ .filter(({ type }) => !shouldNotBeIncludedInSchema(includedQueryTypes, type))
3694
+ .flatMap(({ params }) => Object.values(params).map(({ type }) => getNamedType(type))),
3198
3695
  ];
3199
3696
 
3200
3697
  const queryNameConflict = (name) => new SimfinityError(
@@ -3202,14 +3699,53 @@ const queryNameConflict = (name) => new SimfinityError(
3202
3699
  'QUERY_NAME_CONFLICT', 500,
3203
3700
  );
3204
3701
 
3702
+ // The root names of the generated queries of the endpoint types a schema includes.
3703
+ const generatedQueryNames = (includedQueryTypes) => Object.values(typesDict.types)
3704
+ .filter((type) => type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
3705
+ .flatMap((type) => [type.simpleEntityEndpointName, type.listEntitiesEndpointName, `${type.listEntitiesEndpointName}_aggregate`]);
3706
+
3707
+ const isIncludedCustomQuery = (name, includedCustomQueries) => Object.hasOwn(registeredQueries, name)
3708
+ && !shouldNotBeIncludedInSchema(includedCustomQueries, name);
3709
+
3205
3710
  const assertQueryNamesAvailable = (includedQueryTypes, includedCustomQueries) => {
3206
- for (const type of Object.values(typesDict.types)) {
3207
- if (!type.simpleEntityEndpointName || shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype)) continue;
3208
- for (const name of [type.simpleEntityEndpointName, type.listEntitiesEndpointName, `${type.listEntitiesEndpointName}_aggregate`]) {
3209
- if (Object.hasOwn(registeredQueries, name) && !shouldNotBeIncludedInSchema(includedCustomQueries, name)) {
3210
- throw queryNameConflict(name);
3711
+ for (const name of generatedQueryNames(includedQueryTypes)) {
3712
+ if (isIncludedCustomQuery(name, includedCustomQueries)) throw queryNameConflict(name);
3713
+ }
3714
+ };
3715
+
3716
+ // Every registered view, whether or not the schema includes it: its type is an endpoint type of this
3717
+ // runtime, and its params are valid argument names that are neither fields of the type, which covers
3718
+ // stored, reference, collection and virtual fields, nor the generated list arguments. A view that the
3719
+ // schema includes, because it includes the view's type, also needs a name that no included generated
3720
+ // query or custom query takes.
3721
+ const assertViewsAvailable = (includedQueryTypes, includedCustomQueries) => {
3722
+ const views = Object.values(registeredViews);
3723
+ if (!views.length) return;
3724
+ const generatedNames = new Set(generatedQueryNames(includedQueryTypes));
3725
+ for (const { name, type, params } of views) {
3726
+ const registration = typesDict.types[type.name];
3727
+ if (!registration || registration.gqltype !== type || registration.endpoint !== true
3728
+ || !registration.simpleEntityEndpointName) {
3729
+ throw invalidView(`${name} needs a type registered with connect() in this runtime`);
3730
+ }
3731
+ const fields = type.getFields();
3732
+ for (const paramName of Object.keys(params)) {
3733
+ if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(paramName) || paramName.startsWith('__')) {
3734
+ throw invalidView(`${name} param ${paramName} requires a valid GraphQL name that does not start with __`);
3735
+ }
3736
+ // A protected type's managed version is injected later in createSchema(), but its name is
3737
+ // already unavailable to view params on the first schema, as on every subsequent schema.
3738
+ if (Object.hasOwn(fields, paramName) || (paramName === 'version' && isProtectedType(type))) {
3739
+ throw invalidView(`${name} param ${paramName} has the name of a field of ${type.name}`);
3740
+ }
3741
+ if (LIST_QUERY_ARGS.includes(paramName)) {
3742
+ throw invalidView(`${name} param ${paramName} has the name of a generated list query argument`);
3211
3743
  }
3212
3744
  }
3745
+ if (!shouldNotBeIncludedInSchema(includedQueryTypes, type)
3746
+ && (generatedNames.has(name) || isIncludedCustomQuery(name, includedCustomQueries))) {
3747
+ throw queryNameConflict(name);
3748
+ }
3213
3749
  }
3214
3750
  };
3215
3751
 
@@ -3241,6 +3777,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3241
3777
  // Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
3242
3778
  const { includedCustomQueries } = readSchemaOptions(options);
3243
3779
  assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
3780
+ assertViewsAvailable(includedQueryTypes, includedCustomQueries);
3244
3781
  Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
3245
3782
  const schemaTypes = collectSchemaOutputTypes();
3246
3783
  schemaTypes.forEach(assertNotBoundElsewhere);
@@ -3252,16 +3789,21 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3252
3789
  // An application type with the name of a field metadata type that an earlier schema fixed; one that
3253
3790
  // only generated types or the fields of an input object reach is found when the schema is
3254
3791
  // constructed.
3255
- assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes,
3256
- includedCustomMutations, includedCustomQueries));
3792
+ const applicationTypes = includedApplicationTypes(includedQueryTypes, includedMutationTypes,
3793
+ includedCustomMutations, includedCustomQueries);
3794
+ assertFieldMetadataNamesFree(applicationTypes);
3257
3795
  const referencedTypes = markReferencedTypesForModelGeneration();
3258
3796
  // Entities, which keep their own stored identity: connect() types and relation targets.
3259
3797
  Object.values(typesDict.types).forEach((typeInfo) => {
3260
3798
  typeInfo.storedIdentity = typeInfo.endpoint === true || referencedTypes.has(typeInfo);
3261
3799
  });
3800
+ const { protectedTypes, reserveVersionNames } = assertConcurrencyDeclarations(applicationTypes);
3262
3801
  if (adapter.prepare) {
3263
3802
  adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
3264
3803
  }
3804
+ // prepare() describes protected types from their declaration. Models, inputs, resolvers and query
3805
+ // arguments are built from here on, so they all see the field.
3806
+ protectedTypes.forEach(({ gqltype }) => injectVersionField(gqltype));
3265
3807
 
3266
3808
  Object.values(typesDict.types).forEach((typeInfo) => {
3267
3809
  if (typeInfo.gqltype && !typeInfo.model) {
@@ -3272,6 +3814,8 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3272
3814
  } else if (typeInfo.needsModel) {
3273
3815
  typeInfo.model = adapter.createModel(typeInfo.gqltype, null, { createCollection: false });
3274
3816
  }
3817
+ const { model } = typeInfo;
3818
+ if (model !== null && (typeof model === 'object' || typeof model === 'function')) generatedModels.add(model);
3275
3819
  }
3276
3820
  });
3277
3821
 
@@ -3316,7 +3860,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3316
3860
  includedCustomQueries);
3317
3861
  }
3318
3862
 
3319
- const schema = constructSchema(query, mutation);
3863
+ const schema = constructSchema(query, mutation, reserveVersionNames);
3320
3864
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
3321
3865
  // custom operation result, so another runtime cannot later generate resolvers this schema executes.
3322
3866
  schemaTypes.forEach((type) => {
@@ -3324,6 +3868,10 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3324
3868
  runtimeBoundTypes.set(type, runtimeIdentity);
3325
3869
  }
3326
3870
  });
3871
+ // The schema exists, so each registered type's protection is fixed from now on.
3872
+ Object.values(typesDict.types).forEach(({ gqltype }) => {
3873
+ if (!protectionStatus.has(gqltype)) protectionStatus.set(gqltype, isProtected(gqltype));
3874
+ });
3327
3875
  return schema;
3328
3876
  };
3329
3877
 
@@ -3385,6 +3933,64 @@ const registerCustomQuery = (name, description, inputModel, outputModel, callbac
3385
3933
  registeredQueries[name] = registration;
3386
3934
  };
3387
3935
 
3936
+ const invalidView = (detail) => new SimfinityError(`registerView ${detail}`, 'INVALID_VIEW', 500);
3937
+
3938
+ // A view parameter is a value the client sends as a variable or a literal: a scalar, an enum or a list
3939
+ // of them, each possibly NonNull. An input object is never one, wherever it is nested.
3940
+ const isViewParamType = (type) => {
3941
+ if (isNonNullType(type) || isListType(type)) return isViewParamType(type.ofType);
3942
+ return isScalarType(type) || isEnumType(type);
3943
+ };
3944
+
3945
+ const isNonNullPlainObject = (value) => value !== null && typeof value === 'object' && isPlainObject(value);
3946
+
3947
+ const VIEW_OPTION_KEYS = new Set(['type', 'description', 'params', 'where']);
3948
+ const VIEW_PARAM_KEYS = new Set(['type', 'description', 'defaultValue']);
3949
+
3950
+ const validateViewParams = (params) => {
3951
+ if (params === undefined) return;
3952
+ if (!isNonNullPlainObject(params)) throw invalidView('params must be an object');
3953
+ for (const [paramName, param] of Object.entries(params)) {
3954
+ if (!isNonNullPlainObject(param)) throw invalidView(`param ${paramName} must be an object`);
3955
+ for (const key of Object.keys(param)) {
3956
+ if (!VIEW_PARAM_KEYS.has(key)) throw invalidView(`param ${paramName} has an unknown key ${key}`);
3957
+ }
3958
+ if (!isViewParamType(param.type)) {
3959
+ throw invalidView(`param ${paramName} type must be a scalar, an enum or a list of them, not an input object`);
3960
+ }
3961
+ if (param.description !== undefined && typeof param.description !== 'string') {
3962
+ throw invalidView(`param ${paramName} description must be a string`);
3963
+ }
3964
+ }
3965
+ };
3966
+
3967
+ const validateViewRegistration = (name, options) => {
3968
+ if (typeof name !== 'string' || !/^[_A-Za-z][_0-9A-Za-z]*$/.test(name) || name.startsWith('__')) {
3969
+ throw invalidView('requires a valid GraphQL field name that does not start with __');
3970
+ }
3971
+ if (name.endsWith('_aggregate')) {
3972
+ throw invalidView(`name ${name} must not end with _aggregate, which the generated aggregate queries use`);
3973
+ }
3974
+ if (!isNonNullPlainObject(options)) throw invalidView('requires an options object');
3975
+ for (const key of Object.keys(options)) {
3976
+ if (!VIEW_OPTION_KEYS.has(key)) throw invalidView(`has an unknown option ${key}`);
3977
+ }
3978
+ if (!(options.type instanceof GraphQLObjectType)) throw invalidView('type must be a GraphQLObjectType');
3979
+ if (typeof options.description !== 'string' || options.description === '') {
3980
+ throw invalidView('requires a non-empty description');
3981
+ }
3982
+ validateViewParams(options.params);
3983
+ if (typeof options.where !== 'function') throw invalidView('where must be a function');
3984
+ };
3985
+
3986
+ const registerView = (name, options) => {
3987
+ validateViewRegistration(name, options);
3988
+ if (Object.hasOwn(registeredViews, name)) throw queryNameConflict(name);
3989
+ registeredViews[name] = {
3990
+ name, type: options.type, description: options.description, params: options.params ?? {}, where: options.where,
3991
+ };
3992
+ };
3993
+
3388
3994
  // Gives the field a new extensions object as well as the resolver, so a copy made before keeps
3389
3995
  // untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
3390
3996
  // copying it, when a type is rebuilt from toConfig().
@@ -3429,14 +4035,7 @@ const autoGenerateResolvers = (gqltype) => {
3429
4035
  if (!relatedTypeInfo || !relatedTypeInfo.model) {
3430
4036
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
3431
4037
  }
3432
- const joinedScopes = inspectClientPaths(relatedType, args, 'find');
3433
- const issued = markClientConstructs(assertQueryConditions(relatedType, args, 'find'));
3434
- const params = { type: relatedTypeInfo, args, operation: 'find', context };
3435
- await executeMiddleware(params);
3436
- await executeScope(params);
3437
- await applyJoinedScopes(joinedScopes, params);
3438
- const filling = settleClientArgs(relatedType, params, 'find', issued);
3439
- if (filling) await filling;
4038
+ const params = await prepareRead(relatedTypeInfo, args, context, 'find');
3440
4039
  return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
3441
4040
  }, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
3442
4041
  runtimeBoundTypes.set(gqltype, runtimeIdentity);
@@ -3494,6 +4093,8 @@ const connect = (model, gqltype, simpleEntityEndpointName,
3494
4093
  };
3495
4094
  assertNotBoundElsewhere(gqltype);
3496
4095
  validateScope(gqltype);
4096
+ // The declaration's shape only; it reads extensions, never the fields (#98).
4097
+ concurrencyDeclared(gqltype);
3497
4098
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
3498
4099
 
3499
4100
  invalidateCollectionInputs(gqltype);
@@ -3524,6 +4125,7 @@ const addNoEndpointType = (gqltype) => {
3524
4125
  gqltype, endpoint: false, model: null, needsModel,
3525
4126
  };
3526
4127
  validateScope(gqltype);
4128
+ concurrencyDeclared(gqltype);
3527
4129
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
3528
4130
 
3529
4131
  invalidateCollectionInputs(gqltype);
@@ -3537,7 +4139,9 @@ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
3537
4139
 
3538
4140
  // `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
3539
4141
  // A field named like a generated argument gets no top-level filter either; it is reported once.
3540
- // A virtual field is not part of the query language, so it gets no filter at all.
4142
+ // A virtual field is not part of the query language, so it gets no filter at all, and neither does the
4143
+ // version field of a protected type (#98): a MongoDB record without a stored version reads as 1, which
4144
+ // a filter could not match.
3541
4145
  const createArgsForQuery = (gqltype, omit = null) => {
3542
4146
  const argsObject = {};
3543
4147
  const argTypes = gqltype.getFields();
@@ -3550,7 +4154,7 @@ const createArgsForQuery = (gqltype, omit = null) => {
3550
4154
  }
3551
4155
 
3552
4156
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
3553
- if (fieldEntryName === omit || isVirtualField(fieldEntry)) continue;
4157
+ if (fieldEntryName === omit || isVirtualField(fieldEntry) || isVersionField(fieldEntry)) continue;
3554
4158
  let filterType = null;
3555
4159
 
3556
4160
  if (fieldEntry.type instanceof GraphQLScalarType
@@ -3622,6 +4226,7 @@ function formatArgs(argsArray) {
3622
4226
  use,
3623
4227
  registerMutation,
3624
4228
  registerCustomQuery,
4229
+ registerView,
3625
4230
  saveObject,
3626
4231
  preventCreatingCollection,
3627
4232
  };