@simtlix/simfinity-core 3.5.9 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
16
16
  - `preventCreatingCollection` to pass validation-only storage setup to an adapter;
17
17
  - `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size and nested-operation limits.
18
18
 
19
- The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize or reject it. `castId` should throw a `NOT_VALID_ID` (400) `SimfinityError` for a malformed value and must not create a new identifier. When `getById` rejects a stored reference value that way, including one that `castId` rejected first, the generated reference field fails with an `InternalServerError` (500), since the client did not send the value. `update()` resolves to the updated record as stored, or null; nested collection children of that record are linked to and checked against its `_id`, else `id`, else the requested ID. An adapter that hydrates records can define `readEmbeddedValue(value)`: every nullable, singular embedded object field without a resolver then passes its value through it, so an explicitly stored `null` reads as `null`. The field is read as graphql's default resolver reads it, so a method on the parent is called and a promise it returns is awaited before the hook receives the value. Simfinity then reads as `null` an object that holds no data when a required member would read as `null`, not counting `id`, `readOnly`, interface and union members, members with an application resolver and referenced collections; runtimes that share the type read the same. It reads the stored members, never calling a getter, and counts a stored `_id` as `id`; it reads nothing from objects whose type has no member that counts, and an error while reading returns the object unchanged. An adapter whose hydrated records render members through getters can also define `rawEmbeddedValue(value)`, which returns a record's stored data as a plain object, or the record unchanged when that data does not show what it renders; a value that is not a plain object counts as data. The MongoDB adapter defines both.
19
+ The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize or reject it. `castId` should throw a `NOT_VALID_ID` (400) `SimfinityError` for a malformed value and must not create a new identifier. An adapter can define `readByIdIsInert(model)`, returning `true` when a `getById` of that model has no side effects and `update()` and `delete()` reject the same malformed IDs with `NOT_VALID_ID` (400) and return null for a missing record; the runtime then skips the reads before deletes without an `onDelete` hook and before updates that merge no embedded value and have neither `onUpdating` nor nested collection items. The SQL adapter always reports it, and the MongoDB adapter does for models without `find`, `findOne`, `findOneAndUpdate` or `findOneAndDelete` middleware and without application `_id` setters or overrides of the statics those reads and writes call. When `getById` rejects a stored reference value that way, including one that `castId` rejected first, the generated reference field fails with an `InternalServerError` (500), since the client did not send the value. `update()` resolves to the updated record as stored, or null; nested collection children of that record are linked to and checked against its `_id`, else `id`, else the requested ID. An adapter that hydrates records can define `readEmbeddedValue(value)`: every nullable, singular embedded object field without a resolver then passes its value through it, so an explicitly stored `null` reads as `null`. The field is read as graphql's default resolver reads it, so a method on the parent is called and a promise it returns is awaited before the hook receives the value. Simfinity then reads as `null` an object that holds no data when a required member would read as `null`, not counting `id`, `readOnly`, interface and union members, members with an application resolver and referenced collections; runtimes that share the type read the same. It reads the stored members, never calling a getter, and counts a stored `_id` as `id`; it reads nothing from objects whose type has no member that counts, and an error while reading returns the object unchanged. An adapter whose hydrated records render members through getters can also define `rawEmbeddedValue(value)`, which returns a record's stored data as a plain object, or the record unchanged when that data does not show what it renders; a value that is not a plain object counts as data. The MongoDB adapter defines both.
20
20
 
21
21
  Entity types, those registered with `connect()` and the targets of non-embedded relations, resolve `id` to `_id`, falling back to `id`, whatever the `createSchema()` allowlists include; types used only as embedded values keep their declared `id`. Embedded copies of an entity type resolve the same way, so they return their declared `id` unless the adapter stores an `_id` for them, as MongoDB does for embedded list items. The resolver is set on the type object, so it applies in every runtime that shares the type. `createSchema()` marks these registrations with `storedIdentity: true` before calling `adapter.prepare()`.
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.5.9",
3
+ "version": "3.6.0",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/runtime.js CHANGED
@@ -427,6 +427,21 @@ const cloneInput = (value, type) => {
427
427
  return value;
428
428
  };
429
429
 
430
+ // Each transaction attempt works on its own copy of the request input, since hooks, validators and
431
+ // state actions may change it. The first copy is taken now, before the transaction starts, and is
432
+ // handed out once; a retry copies the request input again, which no attempt receives or changes.
433
+ const attemptInputs = (input, type) => {
434
+ let first = cloneInput(input, type);
435
+ let taken = false;
436
+ return () => {
437
+ if (taken) return cloneInput(input, type);
438
+ taken = true;
439
+ const value = first;
440
+ first = undefined;
441
+ return value;
442
+ };
443
+ };
444
+
430
445
  export const createRuntime = (adapter) => {
431
446
  if (!adapter) {
432
447
  throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
@@ -1246,10 +1261,10 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
1246
1261
  };
1247
1262
 
1248
1263
  const executeRegisteredMutation = (args, callback, context, session, inputModel) => {
1249
- const inputSnapshot = cloneInput(args, inputModel);
1264
+ const nextInput = attemptInputs(args, inputModel);
1250
1265
  return adapter.withTransaction(
1251
1266
  session,
1252
- (mySession) => callback(cloneInput(inputSnapshot, inputModel), mySession, context),
1267
+ (mySession) => callback(nextInput(), mySession, context),
1253
1268
  );
1254
1269
  };
1255
1270
 
@@ -1270,10 +1285,23 @@ const iterateOnCollectionFields = async (materializedModel, gqltype, objectId, s
1270
1285
  }
1271
1286
  };
1272
1287
 
1273
- const onDelete = async (Model, controller, id, session, context) => {
1274
- const currentObject = await adapter.getById(Model, id, session, { plain: true });
1288
+ // Whether the runtime may skip a read by ID whose result it does not use: the adapter reports that
1289
+ // such a read of the model has no side effects, such as query middleware, and fails only where the
1290
+ // update or delete by that ID fails too. Without the report, those reads run as before.
1291
+ const readByIdIsInert = (Model) => typeof adapter.readByIdIsInert === 'function'
1292
+ && adapter.readByIdIsInert(Model) === true;
1293
+
1294
+ // The record is read for the onDelete hook; adapter.delete casts the ID, deletes and returns the
1295
+ // record itself. Without a hook, the read is skipped when it is inert. A nested delete passes the
1296
+ // child it has already read as `current`.
1297
+ const onDelete = async (Model, controller, id, session, context, current) => {
1298
+ const hasHook = !!(controller && controller.onDelete);
1299
+ let currentObject = current;
1300
+ if (currentObject === undefined && (hasHook || !readByIdIsInert(Model))) {
1301
+ currentObject = await adapter.getById(Model, id, session, { plain: true });
1302
+ }
1275
1303
 
1276
- if (controller && controller.onDelete) {
1304
+ if (hasHook) {
1277
1305
  await controller.onDelete(currentObject, session, context);
1278
1306
  }
1279
1307
 
@@ -1346,13 +1374,35 @@ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
1346
1374
  }
1347
1375
  };
1348
1376
 
1349
- const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
1377
+ // Whether an update reads the stored embedded values of a type with embedded fields. They are used
1378
+ // to merge a singular embedded object, and a non-empty embedded list is completed only for an
1379
+ // existing record; empty lists and nulls use nothing. A top-level update also keeps the read before
1380
+ // onUpdating, so that a malformed ID fails before the hook, and before nested collection items, so
1381
+ // that the SQL adapter locks the parent row before child writes; otherwise it skips the read only
1382
+ // when it is inert. A state action or a nested update has already read and checked the record
1383
+ // (`recordChecked`).
1384
+ const readsStoredEmbedded = (Model, controller, materializedModel, embeddedFieldNames, recordChecked) => {
1385
+ const usesStored = embeddedFieldNames.some((name) => {
1386
+ const value = ownValue(materializedModel.modelArgs, name);
1387
+ return value && (!Array.isArray(value) || value.length > 0);
1388
+ });
1389
+ if (usesStored) return true;
1390
+ if (recordChecked) return false;
1391
+ return !!(controller && controller.onUpdating)
1392
+ || Object.values(materializedModel.collectionFields).some((field) => field
1393
+ && ['added', 'updated', 'deleted'].some((key) => field[key] && hasNestedItems(field[key])))
1394
+ || !readByIdIsInert(Model);
1395
+ };
1396
+
1397
+ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context,
1398
+ recordChecked = false) => {
1350
1399
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
1351
1400
  const objectId = args.id;
1352
1401
  const argTypes = gqltype.getFields();
1353
1402
  const embeddedFieldNames = getEmbeddedFieldNames(gqltype);
1354
1403
 
1355
- if (embeddedFieldNames.length > 0) {
1404
+ if (embeddedFieldNames.length > 0
1405
+ && readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked)) {
1356
1406
  const projection = Object.fromEntries(embeddedFieldNames.map((name) => [name, 1]));
1357
1407
  const currentObject = await adapter.getById(Model, objectId, session, {
1358
1408
  projection,
@@ -1365,7 +1415,6 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1365
1415
  const newObjectData = ownValue(materializedModel.modelArgs, fieldEntryName);
1366
1416
  if (newObjectData) {
1367
1417
  if (Array.isArray(newObjectData)) {
1368
- materializedModel.modelArgs[fieldEntryName] = newObjectData;
1369
1418
  completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName], newObjectData);
1370
1419
  } else {
1371
1420
  materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
@@ -1427,7 +1476,7 @@ const onStateChanged = async (Model, gqltype, controller, args, session, actionF
1427
1476
  }
1428
1477
 
1429
1478
  args.state = stateValue(actionField.to);
1430
- let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context);
1479
+ let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context, true);
1431
1480
  result = adapter.toObject(result);
1432
1481
  result.state = actionField.to.value;
1433
1482
  return result;
@@ -1543,13 +1592,11 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
1543
1592
  const limit = getMaxNestedOperations();
1544
1593
  if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
1545
1594
  if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
1546
- const inputSnapshot = operation === operations.DELETE ? args : cloneInput(args, inputType);
1595
+ const nextInput = operation === operations.DELETE ? () => args : attemptInputs(args, inputType);
1547
1596
  return adapter.withTransaction(
1548
1597
  session,
1549
1598
  async (mySession) => {
1550
- const attemptArgs = operation === operations.DELETE
1551
- ? inputSnapshot
1552
- : cloneInput(inputSnapshot, inputType);
1599
+ const attemptArgs = nextInput();
1553
1600
  switch (operation) {
1554
1601
  case operations.SAVE:
1555
1602
  return onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
@@ -1594,9 +1641,12 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1594
1641
  const args = operationType === operations.DELETE ? { id: element } : { input: element };
1595
1642
  await executeMiddleware({ type, args, operation: operationType, context });
1596
1643
 
1644
+ // The ownership check reads the child (the SQL adapter locks its row). onDelete receives this
1645
+ // record, and an update reads the child again only to merge embedded values.
1646
+ let child;
1597
1647
  if (operationType === operations.UPDATE || operationType === operations.DELETE) {
1598
1648
  const id = operationType === operations.DELETE ? args.id : args.input.id;
1599
- const child = await adapter.getById(type.model, id, session, { plain: true, lock: true });
1649
+ child = await adapter.getById(type.model, id, session, { plain: true, lock: true });
1600
1650
  if (!child) {
1601
1651
  throw new SimfinityError(`${collectionGQLType.name} ${id} is not valid`, 'NOT_VALID_ID', 404);
1602
1652
  }
@@ -1612,10 +1662,10 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1612
1662
  break;
1613
1663
  case operations.UPDATE:
1614
1664
  await onUpdateSubject(type.model, collectionGQLType, type.controller,
1615
- args.input, session, linkToParent, context);
1665
+ args.input, session, linkToParent, context, true);
1616
1666
  break;
1617
1667
  case operations.DELETE:
1618
- await onDelete(type.model, type.controller, args.id, session, context);
1668
+ await onDelete(type.model, type.controller, args.id, session, context, child);
1619
1669
  }
1620
1670
  }
1621
1671
  };
package/types/index.d.ts CHANGED
@@ -323,6 +323,16 @@ export interface DatabaseAdapter<Model = any, Session = any> {
323
323
  getById(model: Model, id: any, session?: Session | null, options?: { projection?: Record<string, number>; plain?: boolean; lock?: boolean; requiredId?: any; context?: any }): any;
324
324
  /** Optional batch read: the records found for `ids`, in getById shape and any order. After a failure, the runtime reads each ID with getById. */
325
325
  getByIds?(model: Model, ids: any[], options?: { context?: any }): any;
326
+ /**
327
+ * Optional. Return true when a `getById` of `model` has no side effects (no query middleware or
328
+ * hooks that guard, count or change reads) and cannot fail where `update` or `delete` by the same
329
+ * ID would succeed: both reject a malformed ID with `NOT_VALID_ID` (400) as the read does, and
330
+ * return null for a missing record. The runtime may then skip a read whose result it does not use:
331
+ * the read before a delete without an `onDelete` hook, and, for a type with embedded fields, the
332
+ * read before an update that merges no embedded value and has neither `onUpdating` nor nested
333
+ * collection items. When it is absent or returns false, those reads run.
334
+ */
335
+ readByIdIsInert?(model: Model): boolean;
326
336
  prepareUpdate(set: Record<string, any>, unset: Record<string, string>): any;
327
337
  update(model: Model, id: any, changes: any, session?: Session): any;
328
338
  delete(model: Model, id: any, session?: Session): any;