@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/README.md CHANGED
@@ -8,12 +8,13 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
8
8
 
9
9
  ## Runtime
10
10
 
11
- `createRuntime(adapter)` creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom queries and mutations, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
11
+ `createRuntime(adapter)` creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom queries and mutations, views, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
12
12
 
13
13
  - `connect`, `addNoEndpointType`, and `createSchema` for registration and schema creation;
14
14
  - `getModel`, `getType`, `getInputType`, and `getRegistrations` for runtime inspection;
15
15
  - `use`, `registerMutation`, and `saveObject` for middleware and write orchestration;
16
16
  - `registerCustomQuery` for reads whose results are not stored records, such as previews, other sources or permission evaluations;
17
+ - `registerView` for a named list read of one registered type, with params and a fixed predicate, run through the type's scope;
17
18
  - `preventCreatingCollection` to pass validation-only storage setup to an adapter;
18
19
  - `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size, reference-frame and nested-operation limits.
19
20
 
@@ -44,3 +45,11 @@ Importing the runtime installs Simfinity's `__Field.extensions` introspection fi
44
45
  The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `UnauthenticatedError` and `ForbiddenError` (the same classes as `auth.UnauthenticatedError` and `auth.ForbiddenError`), `buildErrorFormatter`, `configureQueryLimits`, `configureMutationLimits`, `QLOperator`, `QLSort`, and `QLValue`. `buildErrorFormatter` returns a `GraphQLError` whose `originalError` is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
45
46
 
46
47
  The same helpers are available from the `@simtlix/simfinity-core/auth`, `/auth/errors`, `/auth/expressions`, `/auth/rules`, `/plugins`, `/scalars` and `/validators` subpaths, each with TypeScript declarations whose types match the root namespaces. `moduleResolution` `node16`, `nodenext` and `bundler` read them through the export map, and `node10` through `typesVersions`. The `internal/*` subpaths are not public API and have no declarations.
48
+
49
+ ## Optimistic concurrency
50
+
51
+ `extensions: { concurrency: { mode: 'optimistic' } }` opts an entity into the managed `version: RecordVersion!` field. Core exports `RecordVersion` and `MAX_RECORD_VERSION` (`9007199254740991`); import from the application's facade to share their identity. See the [concurrency guide](https://simtlix.github.io/simfinity.js/guide/concurrency.html).
52
+
53
+ An adapter advertises `writeFeatures: { optimisticConcurrency: true }`; `createRuntime()` snapshots/freezes this declaration and rejects invalid shapes with `INVALID_WRITE_FEATURES` (500). A protected schema rejects an adapter without support. `update(model, id, changes, session, { expectedVersion })` must atomically match ID/version, apply changes and set `expectedVersion + 1`, returning the updated record; `delete(model, id, session, { expectedVersion })` must atomically match both and return the deleted record. An update at the maximum skips the write. After no match, read the same cast key in the same session: return `null` if absent, report `VERSION_CONFLICT` if stale or `VERSION_EXHAUSTED` for an equal maximum on update, otherwise return `null`. MongoDB missing/null versions count as 1. Without `expectedVersion`, existing writes and call paths remain unchanged.
54
+
55
+ Support also requires `find(model, type, args, session, { requiredId, context })` to honor the session, required key and context for scoped visibility checks. Wrappers must forward every argument, including options. Core's update result diagnostic requires `expected + 1` and advancement beyond its immutable pre-read version; delete results must carry the expected version. These diagnostics raise `DATABASE_ERROR` for inconsistent results but cannot prove arbitrary wrappers kept the atomic predicate. They retain supplied-session ownership; adapters must preserve their own guarded-write abort semantics. The `get_by_id` scope defers stale/exhausted errors for hidden callers until write hooks authorize the operation, but malformed stored versions can fail before hooks. Protected writes always perform their required pre-read, regardless of `readByIdIsInert`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.11.0",
3
+ "version": "3.13.0",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "./internal/object-id": "./src/auth/object-id.js",
55
55
  "./internal/query-operators": "./src/query-operators.js",
56
+ "./internal/record-version": "./src/record-version.js",
56
57
  "./internal/relation-storage": "./src/relation-storage.js",
57
58
  "./plugins": {
58
59
  "types": "./types/plugins.d.ts",
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { describeModels } from './metadata.js';
2
2
  export { isVirtualField, virtualFieldOptions } from './relation-storage.js';
3
+ export { MAX_RECORD_VERSION, RecordVersion } from './record-version.js';
3
4
  export { default as SimfinityError } from './errors/simfinity.error.js';
4
5
  export { createValidatedScalar } from './scalars/factory.js';
5
6
  export {
package/src/metadata.js CHANGED
@@ -6,6 +6,10 @@ import {
6
6
  assertVirtualConnection, assertVirtualDeclaration, assertVirtualIndexes, assertVirtualStateField,
7
7
  } from './virtual-fields.js';
8
8
  import { assertPlainConnectionField } from './relation-storage.js';
9
+ import {
10
+ isProtectedType, isVersionField, recordVersionFieldMisuse, versionConnectionCollision, versionEmbeddedMisuse,
11
+ versionFieldCollision,
12
+ } from './record-version.js';
9
13
 
10
14
  const invalid = (message) => {
11
15
  throw new SimfinityError(message, 'INVALID_MODEL', 400);
@@ -62,10 +66,20 @@ const scalarDescription = (type) => {
62
66
  return { scalar };
63
67
  };
64
68
 
69
+ // The version field of a protected entity, described the same way whether the type carries Simfinity's
70
+ // injected field or only declares the extension and createSchema() has not injected it yet.
71
+ const versionDescription = () => ({
72
+ name: 'version', storageName: 'version', kind: 'scalar', scalar: 'Version', required: true,
73
+ list: false, itemRequired: false, unique: false, readOnly: true, version: true,
74
+ });
75
+
65
76
  /**
66
77
  * Describe persistent entities without importing a database driver or mutating GraphQL types.
67
78
  * Virtual fields (`extensions.virtual`) store nothing, so they are left out of every entity and
68
79
  * embedded type, and their result types are neither described nor required to declare a relation.
80
+ * An entity that declares optimistic concurrency (`extensions.concurrency`), or already carries its
81
+ * injected field, ends with the version field. A field that would collide with it, any other field
82
+ * typed RecordVersion, and a protected type used as an embedded value throw INVALID_MODEL.
69
83
  */
70
84
  export const describeModels = (registrations) => {
71
85
  if (!Array.isArray(registrations)) invalid('registrations must be an array');
@@ -91,10 +105,17 @@ export const describeModels = (registrations) => {
91
105
  const fieldsOf = (type, ancestors, path, embedded = false) => {
92
106
  if (ancestors.includes(type)) invalid(`Embedded cycle at ${path}`);
93
107
  registerType(type);
108
+ const isProtected = isProtectedType(type);
109
+ if (isProtected && embedded) invalid(versionEmbeddedMisuse(type.name, path));
110
+ const collision = isProtected ? versionFieldCollision(type) : null;
111
+ if (collision) invalid(collision);
112
+ const misuse = recordVersionFieldMisuse(type);
113
+ if (misuse) invalid(misuse);
94
114
  const stored = Object.values(type.getFields()).filter((field) => !assertVirtualDeclaration(
95
115
  type.name, field.name, field, () => typeof field.resolve === 'function',
96
116
  ));
97
117
  const fields = stored.map((field) => {
118
+ if (isVersionField(field)) return versionDescription();
98
119
  const { named, ...wrappers } = unwrap(field.type);
99
120
  const extensions = field.extensions || {};
100
121
  const descriptor = {
@@ -117,12 +138,15 @@ export const describeModels = (registrations) => {
117
138
  assertPlainConnectionField(type.name, field.name, field);
118
139
  addEntity(named);
119
140
  if (wrappers.list) {
141
+ const connectionCollision = versionConnectionCollision(type, field.name, field);
142
+ if (connectionCollision) invalid(connectionCollision);
120
143
  if (!relation.connectionField) invalid(`${path}.${field.name} requires a child connectionField`);
121
144
  if (embedded) invalid(`Non-embedded collections inside embedded objects are not supported: ${path}.${field.name}`);
122
145
  return { ...descriptor, kind: 'collection', target: named.name, connectionField: relation.connectionField };
123
146
  }
124
147
  return { ...descriptor, kind: 'reference', target: named.name, storageName: relation.connectionField || field.name };
125
148
  });
149
+ if (isProtected && !fields.some((item) => item.version)) fields.push(versionDescription());
126
150
  const storage = new Set();
127
151
  for (const field of fields.filter((item) => item.kind !== 'collection')) {
128
152
  if (storage.has(field.storageName)) invalid(`Storage collision at ${path}.${field.storageName}`);
@@ -0,0 +1,235 @@
1
+ import {
2
+ GraphQLError, GraphQLID, GraphQLInputObjectType, GraphQLNonNull, GraphQLScalarType, Kind, getNamedType, isObjectType,
3
+ } from 'graphql';
4
+ import SimfinityError from './errors/simfinity.error.js';
5
+ import { getFieldStorageName, getListShape } from './relation-storage.js';
6
+
7
+ // Everything about declarative optimistic concurrency (`extensions.concurrency`) that does not depend
8
+ // on a runtime: the RecordVersion scalar, the version field Simfinity injects into a protected type,
9
+ // how version values are read and checked, the errors that report them and the collision rules that
10
+ // createSchema() and describeModels() share. The scalar and its maximum are public root exports of the
11
+ // core package; the rest reaches the adapters through the `./internal/record-version` subpath, which
12
+ // is not public API.
13
+
14
+ /** The highest version. A record at it cannot be updated again, because its version cannot advance. */
15
+ export const MAX_RECORD_VERSION = Number.MAX_SAFE_INTEGER;
16
+
17
+ const RANGE = `an integer from 1 to ${MAX_RECORD_VERSION}`;
18
+
19
+ const isVersionNumber = (value) => Number.isSafeInteger(value) && value >= 1;
20
+
21
+ // A stored or serialized version as a number: a safe integer from 1 to MAX_RECORD_VERSION, given as a
22
+ // number or as a bigint (a PostgreSQL driver may return one for a bigint column). Null for anything else.
23
+ const asVersion = (value) => {
24
+ if (typeof value === 'bigint') return value >= 1n && value <= BigInt(MAX_RECORD_VERSION) ? Number(value) : null;
25
+ return isVersionNumber(value) ? value : null;
26
+ };
27
+
28
+ // The value as an error message shows it. A string is quoted; a value that cannot be converted to
29
+ // text, such as an object without a prototype, shows its type.
30
+ const show = (value) => {
31
+ try {
32
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
33
+ } catch {
34
+ return typeof value;
35
+ }
36
+ };
37
+
38
+ export const RecordVersion = new GraphQLScalarType({
39
+ name: 'RecordVersion',
40
+ description: 'Revision of a record with optimistic concurrency. Send it back unchanged in updates and deletes; Simfinity advances it.',
41
+ serialize(value) {
42
+ const version = asVersion(value);
43
+ if (version === null) throw new GraphQLError(`RecordVersion cannot represent ${show(value)}`);
44
+ return version;
45
+ },
46
+ // Input is a JSON number only: a string, a float or a bigint is never a version a client read.
47
+ parseValue(value) {
48
+ if (!isVersionNumber(value)) throw new TypeError(`RecordVersion must be ${RANGE}`);
49
+ return value;
50
+ },
51
+ parseLiteral(ast) {
52
+ // An IntValue beyond the safe integers rounds to an unsafe number, so the same check rejects it.
53
+ const version = ast.kind === Kind.INT ? Number(ast.value) : Number.NaN;
54
+ if (!isVersionNumber(version)) throw new GraphQLError(`RecordVersion must be ${RANGE}`, { nodes: ast });
55
+ return version;
56
+ },
57
+ });
58
+
59
+ /** A record of a protected collection, named by its ID and the version the client read. */
60
+ export const RecordVersionRef = new GraphQLInputObjectType({
61
+ name: 'RecordVersionRef',
62
+ fields: {
63
+ id: { type: new GraphQLNonNull(GraphQLID) },
64
+ version: { type: new GraphQLNonNull(RecordVersion) },
65
+ },
66
+ });
67
+
68
+ // The marker of the injected field. Symbol.for gives every copy of the core code the same key, and a
69
+ // null-prototype extensions object keeps it through toConfig() copies and extensions spreads.
70
+ export const RECORD_VERSION_FIELD = Symbol.for('simfinity.recordVersionField');
71
+ // Invalid raw storage values survive native hydration without mutating returned records.
72
+ // Weak keys also support frozen documents and do not keep query results alive.
73
+ const retainedVersions = new WeakMap();
74
+ // Undefined clears metadata when a later query reuses the same result object.
75
+ export const retainStoredVersion = (record, value) => {
76
+ if (value === undefined) retainedVersions.delete(record);
77
+ else retainedVersions.set(record, value);
78
+ };
79
+ export const retainedStoredVersion = (record) => retainedVersions.get(record);
80
+
81
+ /** Whether a field is the version field that Simfinity injected into a protected type. */
82
+ export const isVersionField = (field) => field?.extensions?.[RECORD_VERSION_FIELD] === true;
83
+
84
+ const VERSION_FIELD_TYPE = new GraphQLNonNull(RecordVersion);
85
+
86
+ /**
87
+ * The graphql-js field object that createSchema() puts into a protected type's `getFields()` map, with
88
+ * `resolve` as its resolver. `readOnly: true` keeps it out of the create input and `queryable: false`
89
+ * out of filters, sorts and aggregations; the marker tells it from an application field.
90
+ */
91
+ export const createVersionFieldConfig = (resolve) => ({
92
+ name: 'version',
93
+ description: 'Revision of this record. Send it back unchanged in updates and deletes; Simfinity advances it.',
94
+ type: VERSION_FIELD_TYPE,
95
+ args: [],
96
+ resolve,
97
+ deprecationReason: undefined,
98
+ extensions: Object.assign(Object.create(null), { readOnly: true, queryable: false, [RECORD_VERSION_FIELD]: true }),
99
+ astNode: undefined,
100
+ });
101
+
102
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
103
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
104
+
105
+ /**
106
+ * Whether the type declares optimistic concurrency. `extensions.concurrency` is either absent or a plain
107
+ * object whose only key is `mode`, with the value 'optimistic'; any other value throws INVALID_MODEL
108
+ * (400), whatever the key (own, hidden or a symbol) that makes it so.
109
+ */
110
+ export const concurrencyDeclared = (gqltype) => {
111
+ const declaration = gqltype.extensions?.concurrency;
112
+ if (declaration === undefined) return false;
113
+ const keys = isPlainObject(declaration) ? Reflect.ownKeys(declaration) : [];
114
+ if (keys.length !== 1 || keys[0] !== 'mode' || declaration.mode !== 'optimistic') {
115
+ throw new SimfinityError(`${gqltype.name} extensions.concurrency must be { mode: 'optimistic' }`, 'INVALID_MODEL', 400);
116
+ }
117
+ return true;
118
+ };
119
+
120
+ /**
121
+ * A stored version as a number. A missing or null one is 1 (a MongoDB document written before the type
122
+ * was protected); anything but a safe integer from 1 to MAX_RECORD_VERSION, given as a number or a
123
+ * bigint, throws DATABASE_ERROR (500): the stored data is wrong, not the request.
124
+ */
125
+ export const readStoredVersion = (value, { typeName, id } = {}) => {
126
+ if (value === undefined || value === null) return 1;
127
+ const version = asVersion(value);
128
+ if (version === null) {
129
+ const record = [typeName ?? 'Record', id].filter((part) => part !== undefined && part !== null).join(' ');
130
+ throw new SimfinityError(`${record} stores an invalid version`, 'DATABASE_ERROR', 500);
131
+ }
132
+ return version;
133
+ };
134
+
135
+ /**
136
+ * Checks a version that did not come through GraphQL coercion (middleware, saveObject(), a programmatic
137
+ * caller): a missing or null one throws REQUIRED_VALUE and one that is not a number from 1 to
138
+ * MAX_RECORD_VERSION throws INVALID_VERSION, both 400. Returns the version.
139
+ */
140
+ export const assertVersionInput = (value) => {
141
+ if (value === undefined || value === null) throw new SimfinityError('Required value version is missing', 'REQUIRED_VALUE', 400);
142
+ if (!isVersionNumber(value)) throw new SimfinityError(`Invalid value for version: expected ${RANGE}`, 'INVALID_VERSION', 400);
143
+ return value;
144
+ };
145
+
146
+ // None of these messages carries the stored version or other data of the record.
147
+ export const versionConflictError = (typeName, id) => new SimfinityError(
148
+ `${typeName} ${id} was modified by another operation`, 'VERSION_CONFLICT', 409,
149
+ );
150
+
151
+ export const versionExhaustedError = (typeName, id) => new SimfinityError(
152
+ `${typeName} ${id} has reached the maximum version`, 'VERSION_EXHAUSTED', 409,
153
+ );
154
+
155
+ /** `hook` is the name of the hook that set the managed field, such as 'onUpdating'. */
156
+ export const versionManagedError = (typeName, hook) => new SimfinityError(
157
+ `${typeName}.version is managed by Simfinity; ${hook} cannot set it`, 'VERSION_MANAGED', 500,
158
+ );
159
+
160
+ export const unadvancedVersionError = (typeName, id) => new SimfinityError(
161
+ `${typeName} ${id} was written without advancing its version`, 'DATABASE_ERROR', 500,
162
+ );
163
+
164
+ export const unexpectedDeleteError = (typeName, id) => new SimfinityError(
165
+ `${typeName} ${id} was deleted at another version`, 'DATABASE_ERROR', 500,
166
+ );
167
+
168
+ /**
169
+ * Whether the type is protected: it declares the extension, whose shape this checks (INVALID_MODEL), or
170
+ * already carries the injected field. describeModels() and the checks of createSchema() ask this, since
171
+ * they run before the field is injected.
172
+ */
173
+ export const isProtectedType = (gqltype) => concurrencyDeclared(gqltype) || Object.values(gqltype.getFields()).some(isVersionField);
174
+
175
+ /**
176
+ * The INVALID_MODEL message for a protected type used as an embedded value at `path`, such as
177
+ * `Pedido.items`: an embedded value's changes belong to the revision of the record that embeds it.
178
+ */
179
+ export const versionEmbeddedMisuse = (typeName, path) => `${typeName} declares optimistic concurrency, so it cannot be embedded (${path})`;
180
+
181
+ const collisionPrefix = (typeName) => `${typeName} declares optimistic concurrency, so Simfinity adds its version field, but`;
182
+
183
+ /**
184
+ * For a type that declares optimistic concurrency: the INVALID_MODEL message for the first field that
185
+ * would collide with the version field, or null. That is a field named `version` that Simfinity did not
186
+ * add, or a reference stored under `version` (`connectionField: 'version'`). The injected field itself
187
+ * is never a collision. A field named `version` typed RecordVersion is almost always the injected field
188
+ * copied without its marker, as a spread of its extensions passed to a graphql-js constructor does, so
189
+ * the message says how to keep it.
190
+ */
191
+ export const versionFieldCollision = (gqltype) => {
192
+ for (const [name, field] of Object.entries(gqltype.getFields())) {
193
+ if (isVersionField(field)) continue;
194
+ if (name === 'version') {
195
+ const copied = getNamedType(field.type) === RecordVersion
196
+ ? '. It is typed RecordVersion, so it was probably copied from a protected type without its marker: keep '
197
+ + 'the field\'s extensions object as it is, for example with toConfig()'
198
+ : '';
199
+ return `${collisionPrefix(gqltype.name)} ${gqltype.name} already has a field named version; rename that field${copied}`;
200
+ }
201
+ if (getFieldStorageName(name, field) === 'version') {
202
+ return `${collisionPrefix(gqltype.name)} ${gqltype.name}.${name} uses version as its connectionField; choose another one`;
203
+ }
204
+ }
205
+ return null;
206
+ };
207
+
208
+ /**
209
+ * For a collection (a list relation that is not embedded) of `ownerType.fieldName` whose item type
210
+ * declares optimistic concurrency: the INVALID_MODEL message when the collection links its items through
211
+ * `version`, by its connectionField or, without one, by its own field name; otherwise null. The link
212
+ * would resolve to the injected version field instead of a field of the application.
213
+ */
214
+ export const versionConnectionCollision = (ownerType, fieldName, field) => {
215
+ const relation = field.extensions?.relation;
216
+ const itemType = getListShape(field.type)?.itemType;
217
+ if (!relation || relation.embedded || !isObjectType(itemType)) return null;
218
+ if ((relation.connectionField || fieldName) !== 'version' || !isProtectedType(itemType)) return null;
219
+ return `${collisionPrefix(itemType.name)} ${ownerType.name}.${fieldName} links its items through version; use another connectionField`;
220
+ };
221
+
222
+ /**
223
+ * The INVALID_MODEL message for the first field of the type that is typed RecordVersion (the instance
224
+ * this module exports, single or list, required or not) without being the injected version field, or
225
+ * null. Only the managed field may use the scalar in a type; a registered mutation or custom query may
226
+ * still use it in its own input and output types.
227
+ */
228
+ export const recordVersionFieldMisuse = (gqltype) => {
229
+ for (const [name, field] of Object.entries(gqltype.getFields())) {
230
+ if (getNamedType(field.type) === RecordVersion && !isVersionField(field)) {
231
+ return `${gqltype.name}.${name} uses RecordVersion, which only the managed version field may use`;
232
+ }
233
+ }
234
+ return null;
235
+ };