@simtlix/simfinity-core 3.14.0 → 3.16.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.
@@ -0,0 +1,71 @@
1
+ import { nestedOperationsExceededError } from './mutation-limits.js';
2
+
3
+ // Internal, and never exported from the package. The state of one generated transaction attempt (#151):
4
+ // the mutation limits it snapshotted at its start and its joint count of nested collection entries.
5
+ // A state is attached to the session of its attempt for as long as the attempt runs, so every nested
6
+ // entry that runs on that session counts against the same budget, wherever it started: the
7
+ // operations, their middleware, actions and controllers, and the lifecycle helpers and saveObject
8
+ // calls those controllers make. Nothing public reads it and it never travels in `context`.
9
+ //
10
+ // session -> the attachments it holds, oldest first, as `{ state, holds }`. The latest one is the
11
+ // session's state. Attaching the latest state again adds a hold instead of an entry, and a detach
12
+ // releases one hold, so an attachment lasts until the last caller that made it detaches, and the
13
+ // state it replaced is the session's state again once it is gone.
14
+ const attachments = new WeakMap();
15
+
16
+ const isUnlimited = (limit) => limit === null || limit === undefined;
17
+
18
+ // runtimeToken is the identity of the runtime that owns the attempt (see createRuntime), so that a
19
+ // session lent to another runtime finds no state.
20
+ export const createExecutionState = ({ runtimeToken, limits: { maxNestedOperations, maxTransactionOperations } }) => ({
21
+ runtimeToken,
22
+ limits: Object.freeze({ maxNestedOperations, maxTransactionOperations }),
23
+ nestedCount: 0,
24
+ });
25
+
26
+ // The same state may be attached to more than one session: when the adapter's withTransaction hands a
27
+ // body another object than the one it was given, the state follows it for the duration of the body.
28
+ export const attachExecutionState = (session, state) => {
29
+ let held = attachments.get(session);
30
+ if (held === undefined) {
31
+ held = [];
32
+ attachments.set(session, held);
33
+ }
34
+ const latest = held[held.length - 1];
35
+ if (latest?.state === state) latest.holds += 1;
36
+ else held.push({ state, holds: 1 });
37
+ };
38
+
39
+ // Releases one hold of the latest attachment of that state; a session without it is left as it is.
40
+ export const detachExecutionState = (session, state) => {
41
+ const held = attachments.get(session);
42
+ if (held === undefined) return;
43
+ for (let index = held.length - 1; index >= 0; index -= 1) {
44
+ if (held[index].state === state) {
45
+ held[index].holds -= 1;
46
+ if (held[index].holds === 0) held.splice(index, 1);
47
+ break;
48
+ }
49
+ }
50
+ if (held.length === 0) attachments.delete(session);
51
+ };
52
+
53
+ // WeakMap.get answers undefined for a key that is not an object, so a missing session finds no state.
54
+ export const executionStateOf = (session, runtimeToken) => {
55
+ const held = attachments.get(session);
56
+ const state = held?.[held.length - 1].state;
57
+ return state?.runtimeToken === runtimeToken ? state : undefined;
58
+ };
59
+
60
+ // One call per item that executeItemFunction iterates, before the item runs.
61
+ export const countNestedEntry = (state) => {
62
+ state.nestedCount += 1;
63
+ const limit = state.limits.maxNestedOperations;
64
+ if (!isUnlimited(limit) && state.nestedCount > limit) throw nestedOperationsExceededError(limit);
65
+ };
66
+
67
+ // The nested entries the attempt may still run, or null when it has no limit.
68
+ export const remainingNestedBudget = (state) => {
69
+ const limit = state.limits.maxNestedOperations;
70
+ return isUnlimited(limit) ? null : Math.max(0, limit - state.nestedCount);
71
+ };
@@ -0,0 +1,17 @@
1
+ import { __Field } from 'graphql';
2
+ import SimfinityError from './errors/simfinity.error.js';
3
+ import { introspectionState } from './introspection-state.js';
4
+
5
+ /** Configure before importing the runtime or either database facade. Never removes an extension. */
6
+ export const configureIntrospection = (options) => {
7
+ if (!options || ![Object.prototype, null].includes(Object.getPrototypeOf(options))
8
+ || Reflect.ownKeys(options).length !== 1 || options.fieldExtensions !== false) {
9
+ throw new SimfinityError('configureIntrospection requires only { fieldExtensions: false }',
10
+ 'INVALID_INTROSPECTION_CONFIGURATION', 500);
11
+ }
12
+ if (__Field.getFields().extensions) {
13
+ throw new SimfinityError('Configure fieldExtensions before importing the Simfinity runtime; __Field.extensions is already installed',
14
+ 'INVALID_INTROSPECTION_CONFIGURATION', 500);
15
+ }
16
+ introspectionState().disabled = true;
17
+ };
@@ -0,0 +1,10 @@
1
+ import { __Field } from 'graphql';
2
+
3
+ // Shared only by cooperating core evaluations using the same GraphQL peer. No schema/type mutation.
4
+ const KEY = Symbol.for('simfinity.introspectionConfiguration.v1');
5
+ const states = globalThis[KEY] || new WeakMap();
6
+ if (!globalThis[KEY]) Object.defineProperty(globalThis, KEY, { value: states });
7
+ export const introspectionState = () => {
8
+ if (!states.has(__Field)) states.set(__Field, { disabled: false });
9
+ return states.get(__Field);
10
+ };
@@ -5,6 +5,7 @@ import {
5
5
  __Field,
6
6
  } from 'graphql';
7
7
  import { isVirtualField } from './relation-storage.js';
8
+ import { introspectionState } from './introspection-state.js';
8
9
 
9
10
  // The object types of `__Field.extensions`: FieldExtensionsType and the RelationType of its
10
11
  // `relation` field, under the given names.
@@ -39,7 +40,7 @@ const fieldMetadataOf = (field) => (isVirtualField(field)
39
40
  // getFields() supports both lazy and already materialized introspection fields.
40
41
  // Reuse the shared field on repeated module evaluation to preserve type identity.
41
42
  const introspectionFields = __Field.getFields();
42
- if (!introspectionFields.extensions) {
43
+ if (!introspectionState().disabled && !introspectionFields.extensions) {
43
44
  introspectionFields.extensions = {
44
45
  type: createFieldMetadataType('FieldExtensionsType', 'RelationType'),
45
46
  name: 'extensions',
@@ -56,14 +57,15 @@ const extensionsField = () => __Field.getFields().extensions;
56
57
 
57
58
  /** The metadata types `__Field.extensions` returns now: [FieldExtensionsType, RelationType]. */
58
59
  export const fieldMetadataTypes = () => {
60
+ if (introspectionState().disabled) return [];
59
61
  const extensionsType = extensionsField().type;
60
62
  return [extensionsType, extensionsType.getFields().relation.type];
61
63
  };
62
64
 
63
- export const fieldMetadataNamesFixed = () => extensionsField()[NAMES_FIXED] === true;
65
+ export const fieldMetadataNamesFixed = () => extensionsField()?.[NAMES_FIXED] === true;
64
66
 
65
67
  export const fixFieldMetadataNames = () => {
66
- extensionsField()[NAMES_FIXED] = true;
68
+ if (!introspectionState().disabled) extensionsField()[NAMES_FIXED] = true;
67
69
  };
68
70
 
69
71
  // Every schema constructed afterwards, in the whole process, contains the metadata types under the
@@ -1,24 +1,54 @@
1
1
  import SimfinityError from './errors/simfinity.error.js';
2
2
 
3
+ const OPTION_KEYS = ['maxNestedOperations', 'maxTransactionOperations'];
4
+ const DEFAULT_MAX_TRANSACTION_OPERATIONS = 100;
5
+
3
6
  // Process-wide, like the query limits. null leaves nested collection operations unlimited.
4
7
  let maxNestedOperations = null;
8
+ let maxTransactionOperations = DEFAULT_MAX_TRANSACTION_OPERATIONS;
5
9
 
10
+ // Each call replaces both values: an omitted or undefined option restores its default.
6
11
  export const configureMutationLimits = (options = {}) => {
7
12
  if (!options || typeof options !== 'object' || Array.isArray(options)
8
13
  || ![Object.prototype, null].includes(Object.getPrototypeOf(options))) {
9
14
  throw new SimfinityError('Mutation limit options must be a plain object', 'INVALID_MUTATION_LIMITS', 400);
10
15
  }
11
- // A misspelled option must not silently reset the limit to unlimited.
12
- const unknown = Reflect.ownKeys(options).find((key) => key !== 'maxNestedOperations');
16
+ // A misspelled option must not silently reset a limit to its default.
17
+ const unknown = Reflect.ownKeys(options).find((key) => !OPTION_KEYS.includes(key));
13
18
  if (unknown !== undefined) {
14
- throw new SimfinityError(`Unknown mutation limit option ${String(unknown)}; use maxNestedOperations`,
15
- 'INVALID_MUTATION_LIMITS', 400);
19
+ throw new SimfinityError(
20
+ `Unknown mutation limit option ${String(unknown)}; use maxNestedOperations or maxTransactionOperations`,
21
+ 'INVALID_MUTATION_LIMITS',
22
+ 400,
23
+ );
16
24
  }
17
- const { maxNestedOperations: value = null } = options;
18
- if (value !== null && (!Number.isSafeInteger(value) || value < 0)) {
25
+ const {
26
+ maxNestedOperations: nested = null,
27
+ maxTransactionOperations: transaction = DEFAULT_MAX_TRANSACTION_OPERATIONS,
28
+ } = options;
29
+ if (nested !== null && (!Number.isSafeInteger(nested) || nested < 0)) {
19
30
  throw new SimfinityError('maxNestedOperations must be null or a non-negative safe integer', 'INVALID_MUTATION_LIMITS', 400);
20
31
  }
21
- maxNestedOperations = value;
32
+ if (!Number.isSafeInteger(transaction) || transaction < 1) {
33
+ throw new SimfinityError('maxTransactionOperations must be a positive safe integer', 'INVALID_MUTATION_LIMITS', 400);
34
+ }
35
+ // Validate everything first: a rejected call keeps both current limits.
36
+ maxNestedOperations = nested;
37
+ maxTransactionOperations = transaction;
22
38
  };
23
39
 
24
40
  export const getMaxNestedOperations = () => maxNestedOperations;
41
+
42
+ export const getMaxTransactionOperations = () => maxTransactionOperations;
43
+
44
+ // Internal, like the getters: the package exports only configureMutationLimits. The error of a mutation
45
+ // past maxNestedOperations, which the preflights and a transaction attempt's counter (execution-state.js)
46
+ // raise alike.
47
+ export const nestedOperationsExceededError = (limit) => new SimfinityError(
48
+ `A mutation may perform at most ${limit} nested collection operations`, 'NESTED_OPERATIONS_EXCEEDED', 400,
49
+ );
50
+
51
+ // The error of a generated transaction (#151) whose list is longer than maxTransactionOperations.
52
+ export const transactionOperationsExceededError = (limit) => new SimfinityError(
53
+ `A transaction may run at most ${limit} operations`, 'TRANSACTION_OPERATIONS_EXCEEDED', 400,
54
+ );
@@ -0,0 +1,345 @@
1
+ import {
2
+ GraphQLID, getNamedType, isEnumType, isInputObjectType, isListType, isNonNullType,
3
+ } from 'graphql';
4
+ import SimfinityError from './errors/simfinity.error.js';
5
+ import { RecordVersionRef } from './record-version.js';
6
+
7
+ // Internal, and never exported from the package. The typed walk that validates the inputs of the
8
+ // lifecycle helpers and of the generated transaction (#151) and builds the snapshots they work on.
9
+ // It is pure: it reads the value and its GraphQL input type, calls no scalar function and no hook,
10
+ // and never changes what it is given. Every container, list and plain-data scalar of the result is
11
+ // new, so no two positions of a snapshot share an object and a hook that rewrites one cannot change
12
+ // another, a retry or the caller's input.
13
+
14
+ // Programmatic callers get the whole check; a transaction's operations arrive already coerced by
15
+ // GraphQL, so they only get the scalar and record-key rules; a copy of a snapshot gets no check.
16
+ const MODES = ['programmatic', 'coerced', 'copy'];
17
+
18
+ const UNKNOWN_FIELD = 'unknown field';
19
+ const STATE_FIELD = 'state changes only through transitionObject';
20
+ const EXPECTED_OBJECT = 'expected a plain object';
21
+ const EXPECTED_LIST = 'expected a list';
22
+ const EXPECTED_DENSE_LIST = 'expected a dense list';
23
+ const ACCESSOR = 'accessor property';
24
+ const HIDDEN_PROPERTY = 'non-enumerable or symbol-keyed property';
25
+ const CYCLE = 'cycle';
26
+ const UNSUPPORTED_SCALAR = 'unsupported scalar value';
27
+ const STRING_KEY = 'pass the ID as a string, for example String(record._id)';
28
+ const PRIMITIVE_KEY = 'pass the ID as a primitive value';
29
+ const NESTED_TOO_DEEPLY = 'input is nested too deeply';
30
+
31
+ const requiredValueError = (path) => new SimfinityError(`Required value ${path} is missing`, 'REQUIRED_VALUE', 400);
32
+
33
+ // The message names the path and the rule, never a value.
34
+ const invalidInputError = (path, reason) => new SimfinityError(`Invalid input at ${path}: ${reason}`,
35
+ 'INVALID_INPUT', 400);
36
+
37
+ // How many containers one scalar value may nest. Past it the copy would recurse without a bound: a JSON
38
+ // value of a few thousand nested arrays is a client's to send, and it must be refused as input, not
39
+ // crash with a stack overflow.
40
+ const MAX_SCALAR_DEPTH = 256;
41
+
42
+ // How many input objects and lists an input may nest, the top-level input object included. Past it the walk
43
+ // would recurse without a bound and end in a stack overflow, a 500 where GraphQL's own roots give a 400.
44
+ // Scalar values inside count on their own, against MAX_SCALAR_DEPTH.
45
+ const MAX_INPUT_DEPTH = 256;
46
+
47
+ const intrinsicGetTime = Date.prototype.getTime;
48
+
49
+ // The time of a real Date, read with the intrinsic so that nothing the caller put on the value runs, or
50
+ // undefined when the value is not one (an object that only has the Date prototype, a Proxy of a Date).
51
+ const dateTime = (value) => {
52
+ try {
53
+ return intrinsicGetTime.call(value);
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ };
58
+
59
+ const isPrimitive = (value) => ['string', 'number', 'boolean', 'bigint'].includes(typeof value);
60
+
61
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
62
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
63
+
64
+ // Every copied property is an own data property, even `__proto__`, which assignment would turn into a
65
+ // change of prototype.
66
+ const defineData = (target, key, value) => Object.defineProperty(target, key,
67
+ { value, writable: true, enumerable: true, configurable: true });
68
+
69
+ // The keys the walk visits: with checks, every own key, so that a symbol key is seen and rejected;
70
+ // without them, the enumerable string keys.
71
+ const ownKeysOf = (source, checked) => (checked ? Reflect.ownKeys(source) : Object.keys(source));
72
+
73
+ // The value of an own property, read from its descriptor so that a getter is never run. Only an
74
+ // enumerable data property qualifies. The caller has already refused symbol keys.
75
+ const dataProperty = (source, key, path) => {
76
+ const descriptor = Object.getOwnPropertyDescriptor(source, key);
77
+ // A key that was reported and then has no descriptor (a Proxy, or a property that caller code removed
78
+ // while the walk was running) is as hidden as one that is not enumerable.
79
+ if (descriptor === undefined) throw invalidInputError(path, HIDDEN_PROPERTY);
80
+ if (!('value' in descriptor)) throw invalidInputError(path, ACCESSOR);
81
+ if (!descriptor.enumerable) throw invalidInputError(path, HIDDEN_PROPERTY);
82
+ return descriptor.value;
83
+ };
84
+
85
+ // The items of a list, in order. With checks the list must be dense and made of enumerable data
86
+ // properties; without them it is read as it is.
87
+ const listItems = (list, path, checked) => {
88
+ const items = [];
89
+ for (let index = 0; index < list.length; index += 1) {
90
+ if (!checked) {
91
+ items.push(list[index]);
92
+ } else if (Object.hasOwn(list, index)) {
93
+ items.push(dataProperty(list, index, `${path}[${index}]`));
94
+ } else {
95
+ throw invalidInputError(path, EXPECTED_DENSE_LIST);
96
+ }
97
+ }
98
+ return items;
99
+ };
100
+
101
+ // Cycles are looked for along the path from the root to the value being copied, so an object that two
102
+ // positions share without containing itself is not one. `state.depth` counts the input objects and lists
103
+ // entered, and the one past MAX_INPUT_DEPTH is refused at its own path.
104
+ const enter = (source, path, state) => {
105
+ if (state.ancestors.has(source)) throw invalidInputError(path, CYCLE);
106
+ if (state.depth >= MAX_INPUT_DEPTH) throw invalidInputError(path, NESTED_TOO_DEEPLY);
107
+ state.ancestors.add(source);
108
+ state.depth += 1;
109
+ };
110
+
111
+ const leave = (source, state) => {
112
+ state.ancestors.delete(source);
113
+ state.depth -= 1;
114
+ };
115
+
116
+ // A scalar value as plain data: primitives as they are, `Date` by time, and arrays and objects with
117
+ // the Object or a null prototype deeply. `shared` maps the objects already copied within this one
118
+ // value, so a reference it repeats stays shared in the copy; a position never shares with another.
119
+ // Each copied object keeps its source's prototype and is never frozen. Anything else (a function, a
120
+ // symbol, an instance of another class such as ObjectId, Buffer or Map) is unsupported, and so is a
121
+ // value nested in more than MAX_SCALAR_DEPTH containers. `depth` counts the containers above the value.
122
+ const copyData = (value, path, state, shared, depth = 0) => {
123
+ if (typeof value === 'function' || typeof value === 'symbol') {
124
+ if (state.checkValues) throw invalidInputError(path, UNSUPPORTED_SCALAR);
125
+ return value;
126
+ }
127
+ if (value === null || typeof value !== 'object') return value;
128
+ const isList = Array.isArray(value);
129
+ const time = !isList && Object.getPrototypeOf(value) === Date.prototype ? dateTime(value) : undefined;
130
+ const isDate = time !== undefined;
131
+ if (!isList && !isDate && !isPlainObject(value)) {
132
+ if (state.checkValues) throw invalidInputError(path, UNSUPPORTED_SCALAR);
133
+ return value;
134
+ }
135
+ if (state.ancestors.has(value)) throw invalidInputError(path, CYCLE);
136
+ if (shared.has(value)) return shared.get(value);
137
+ let copy;
138
+ if (isDate) {
139
+ copy = new Date(time);
140
+ } else {
141
+ if (depth >= MAX_SCALAR_DEPTH) throw invalidInputError(path, UNSUPPORTED_SCALAR);
142
+ state.ancestors.add(value);
143
+ if (isList) {
144
+ copy = listItems(value, path, state.checkValues)
145
+ .map((item, index) => copyData(item, `${path}[${index}]`, state, shared, depth + 1));
146
+ } else {
147
+ copy = Object.create(Object.getPrototypeOf(value));
148
+ for (const key of ownKeysOf(value, state.checkValues)) {
149
+ if (typeof key === 'symbol') throw invalidInputError(path, HIDDEN_PROPERTY);
150
+ const keyPath = `${path}.${key}`;
151
+ const item = state.checkValues ? dataProperty(value, key, keyPath) : value[key];
152
+ defineData(copy, key, copyData(item, keyPath, state, shared, depth + 1));
153
+ }
154
+ }
155
+ state.ancestors.delete(value);
156
+ }
157
+ shared.set(value, copy);
158
+ return copy;
159
+ };
160
+
161
+ // A record key: where the declared type is ID, and for IdInputType.id, only a string; where it is
162
+ // another scalar, any primitive. Plain objects, arrays, dates and class instances such as ObjectId are
163
+ // refused, because on a database that casts nothing an `id` of { $ne: null } would select a record (D1).
164
+ const assertRecordKey = (value, kind, path) => {
165
+ const accepted = kind === 'string' ? typeof value === 'string' : isPrimitive(value);
166
+ if (!accepted) throw invalidInputError(path, kind === 'string' ? STRING_KEY : PRIMITIVE_KEY);
167
+ };
168
+
169
+ const keyKind = (declaredType, isIdInput = false) => (
170
+ isIdInput || getNamedType(declaredType) === GraphQLID ? 'string' : 'primitive'
171
+ );
172
+
173
+ const walkScalar = (value, path, state, position) => {
174
+ if (state.checkValues && position.key !== undefined) assertRecordKey(value, position.key, path);
175
+ return copyData(value, path, state, new Map());
176
+ };
177
+
178
+ // The matched internal value is kept by identity. The test is membership under SameValueZero, which is
179
+ // what `includes` uses; it is not parseValue, and only programmatic input needs it, as GraphQL has
180
+ // already checked a transaction's.
181
+ const walkEnum = (value, type, path, state) => {
182
+ if (state.checkShape && !type.getValues().map((entry) => entry.value).includes(value)) {
183
+ throw invalidInputError(path, `not a value of enum ${type.name}`);
184
+ }
185
+ return value;
186
+ };
187
+
188
+ const walkList = (value, type, path, state, position) => {
189
+ if (!Array.isArray(value)) {
190
+ if (state.checkShape) throw invalidInputError(path, EXPECTED_LIST);
191
+ return walkValue(value, type.ofType, path, state, position);
192
+ }
193
+ enter(value, path, state);
194
+ // walkValue turns an `undefined` item into `null`, which executeItemFunction skips.
195
+ const items = listItems(value, path, state.checkShape)
196
+ .map((item, index) => walkValue(item, type.ofType, `${path}[${index}]`, state, position));
197
+ leave(value, state);
198
+ return items;
199
+ };
200
+
201
+ const walkObject = (value, type, path, state, position) => {
202
+ if (state.checkShape && !isPlainObject(value)) throw invalidInputError(path, EXPECTED_OBJECT);
203
+ // Only a coerced or copied value can get here without being an object, and GraphQL does not
204
+ // produce one: it is copied as plain data.
205
+ if (typeof value !== 'object' || Array.isArray(value)) return copyData(value, path, state, new Map());
206
+ enter(value, path, state);
207
+ const fields = type.getFields();
208
+ const isCollection = state.collectionInputTypes?.has(type) === true;
209
+ // The top-level id of an update, a transition or a delete is required whatever its declared type,
210
+ // and it is a record key.
211
+ const isTopLevelId = (key) => key === 'id' && position.topLevel === true && state.requireId;
212
+ const copy = {};
213
+ for (const key of ownKeysOf(value, state.checkShape)) {
214
+ if (typeof key === 'symbol') throw invalidInputError(path, HIDDEN_PROPERTY);
215
+ const fieldPath = `${path}.${key}`;
216
+ const item = state.checkShape ? dataProperty(value, key, fieldPath) : value[key];
217
+ // An own `undefined` is an omitted field, as GraphQL never delivers one.
218
+ if (item === undefined) continue;
219
+ const field = Object.hasOwn(fields, key) ? fields[key] : undefined;
220
+ if (field === undefined) {
221
+ if (state.checkShape) {
222
+ const managedState = position.topLevel && state.stateManaged && key === 'state';
223
+ throw invalidInputError(fieldPath, managedState ? STATE_FIELD : UNKNOWN_FIELD);
224
+ }
225
+ // GraphQL leaves no undeclared field in a coerced input, so there is no rule to apply: the
226
+ // value is kept as plain data.
227
+ defineData(copy, key, walkScalar(item, fieldPath, state, {}));
228
+ continue;
229
+ }
230
+ const fieldPosition = {};
231
+ if (key === 'id' && (isTopLevelId(key) || position.idIsKey || type === state.idInputType
232
+ || type === state.recordVersionRef)) {
233
+ fieldPosition.key = keyKind(field.type, type === state.idInputType);
234
+ }
235
+ if (isCollection && key === 'updated') fieldPosition.idIsKey = true;
236
+ if (isCollection && key === 'deleted') fieldPosition.key = keyKind(field.type);
237
+ if (item === null && isTopLevelId(key)) throw requiredValueError(fieldPath);
238
+ defineData(copy, key, walkValue(item, field.type, fieldPath, state, fieldPosition));
239
+ }
240
+ leave(value, state);
241
+ for (const [name, field] of Object.entries(fields)) {
242
+ const required = (state.checkShape && isNonNullType(field.type)) || isTopLevelId(name);
243
+ if (required && !Object.hasOwn(copy, name)) throw requiredValueError(`${path}.${name}`);
244
+ }
245
+ return copy;
246
+ };
247
+
248
+ const walkValue = (value, type, path, state, position) => {
249
+ if (isNonNullType(type)) {
250
+ if (state.checkShape && (value === null || value === undefined)) throw requiredValueError(path);
251
+ return walkValue(value, type.ofType, path, state, position);
252
+ }
253
+ if (value === null || value === undefined) return null;
254
+ if (isListType(type)) return walkList(value, type, path, state, position);
255
+ if (isInputObjectType(type)) return walkObject(value, type, path, state, position);
256
+ if (isEnumType(type)) return walkEnum(value, type, path, state);
257
+ return walkScalar(value, path, state, position);
258
+ };
259
+
260
+ const createWalkState = ({
261
+ mode = 'programmatic', topLevelId = false, stateManaged = false, collectionInputTypes, idInputType,
262
+ recordVersionRef = RecordVersionRef,
263
+ }) => {
264
+ if (!MODES.includes(mode)) throw new TypeError(`Unknown input walk mode: ${typeof mode === 'string' ? mode : typeof mode}`);
265
+ return {
266
+ // Containers, lists, fields, nullability and enums.
267
+ checkShape: mode === 'programmatic',
268
+ // Scalars and record keys.
269
+ checkValues: mode !== 'copy',
270
+ requireId: topLevelId === true && mode !== 'copy',
271
+ stateManaged: stateManaged === true,
272
+ collectionInputTypes,
273
+ idInputType,
274
+ recordVersionRef,
275
+ ancestors: new Set(),
276
+ depth: 0,
277
+ };
278
+ };
279
+
280
+ /**
281
+ * Validates `value` against an input object type and returns a snapshot of it.
282
+ *
283
+ * `options.mode` is 'programmatic' (the default: the structure rules and the scalar rules, for the
284
+ * lifecycle helpers), 'coerced' (the scalar rules and the record keys only, for the operations of a
285
+ * transaction, which GraphQL has already coerced) or 'copy' (no check, a type-directed copy of an
286
+ * existing snapshot for one attempt). `path` names the root in errors (default `input`). `topLevelId`
287
+ * makes the top-level `id` required and a record key. `collectionInputTypes` is the set of generated
288
+ * collection inputs (`added`, `updated`, `deleted`), `idInputType` the input of a single reference and
289
+ * `recordVersionRef` the input of a versioned delete (default: Simfinity's own), which the walk knows
290
+ * by identity to find their record keys. With `stateManaged`, an undeclared top-level `state` says that
291
+ * state changes only through transitionObject; elsewhere it is an unknown field.
292
+ *
293
+ * Input objects and lists may nest 256 levels, the top-level input included, in every mode; the next one
294
+ * is refused with `input is nested too deeply`.
295
+ *
296
+ * A missing or null value throws REQUIRED_VALUE (400) and any other violation INVALID_INPUT (400), both
297
+ * naming a path such as `input.lines.added[1].price` and never a value. The value is never changed.
298
+ * The result has new, extensible, plain containers; dates and plain data are copied and enum values
299
+ * are kept by identity.
300
+ */
301
+ export const snapshotOperationInput = (value, inputType, options = {}) => {
302
+ const state = createWalkState(options);
303
+ const path = options.path ?? 'input';
304
+ if (!isInputObjectType(isNonNullType(inputType) ? inputType.ofType : inputType)) {
305
+ throw new TypeError('snapshotOperationInput needs an input object type');
306
+ }
307
+ if (value === undefined || value === null) {
308
+ if (state.checkValues) throw requiredValueError(path);
309
+ return value;
310
+ }
311
+ return walkValue(value, inputType, path, state, { topLevel: true });
312
+ };
313
+
314
+ /**
315
+ * The target of `deleteObject`: a plain object with `id` and, on a protected type only, `version`. The
316
+ * id is a record key and must be a string, as the `ID` of the delete root is (D1). Any other key,
317
+ * including `version` on a type that is not protected, is an unknown field. A missing id, or a missing
318
+ * version on a protected type, throws REQUIRED_VALUE; a version that is present but invalid is left for
319
+ * the OCC check inside the attempt. Returns `{ id }` or `{ id, version }`, which are new objects.
320
+ */
321
+ export const readDeleteTarget = (value, { protectedType, path = 'input' } = {}) => {
322
+ if (value === undefined || value === null) throw requiredValueError(path);
323
+ if (!isPlainObject(value)) throw invalidInputError(path, EXPECTED_OBJECT);
324
+ const state = createWalkState({});
325
+ let id;
326
+ let version;
327
+ for (const key of Reflect.ownKeys(value)) {
328
+ if (typeof key === 'symbol') throw invalidInputError(path, HIDDEN_PROPERTY);
329
+ const keyPath = `${path}.${key}`;
330
+ const item = dataProperty(value, key, keyPath);
331
+ if (item === undefined) continue;
332
+ if (key !== 'id' && !(key === 'version' && protectedType)) throw invalidInputError(keyPath, UNKNOWN_FIELD);
333
+ if (item === null) throw requiredValueError(keyPath);
334
+ if (key === 'id') {
335
+ assertRecordKey(item, 'string', keyPath);
336
+ id = item;
337
+ } else {
338
+ version = copyData(item, keyPath, state, new Map());
339
+ }
340
+ }
341
+ if (id === undefined) throw requiredValueError(`${path}.id`);
342
+ if (!protectedType) return { id };
343
+ if (version === undefined) throw requiredValueError(`${path}.version`);
344
+ return { id, version };
345
+ };
@@ -6,7 +6,10 @@ import { isVirtualField } from './relation-storage.js';
6
6
  import {
7
7
  describeEmptiness, describeModelEmptiness, emptinessScopePaths, graphqlStep, isEmptinessOperator, modelStep,
8
8
  } from './query-emptiness.js';
9
- import { isQueryOperator, textQueryOperator, validateTextFilterValue } from './query-operators.js';
9
+ import {
10
+ assertContainsAllPath, isQueryOperator, takesListValue, textQueryOperator, validateContainsAllValue, validateTextFilterValue,
11
+ validateTextListFilterValue,
12
+ } from './query-operators.js';
10
13
  import { collectQueryOperators, validateQueryGroupKeys } from './query-plan.js';
11
14
  import { getQueryMaxFrameNesting } from './query-limits.js';
12
15
 
@@ -281,18 +284,25 @@ const validateShapes = (args) => {
281
284
  };
282
285
  const validateValue = (entry, resolved, ctx) => {
283
286
  const { operator, value } = entry;
287
+ const scalar = resolved.steps.at(-1).scalar;
284
288
  // GraphQL compilers historically report the text operator's own type/value diagnostic.
285
289
  // Model planning retains its generic scalar-shape checks before text validation.
286
290
  if (ctx.gqltype && textQueryOperator(operator)) {
287
- validateTextFilterValue(operator, value, resolved.steps.at(-1).scalar, entry.path);
291
+ validateTextFilterValue(operator, value, scalar, entry.path);
292
+ return;
293
+ }
294
+ if (ctx.gqltype && operator === 'IIN') {
295
+ validateTextListFilterValue(operator, value, scalar, entry.path);
288
296
  return;
289
297
  }
290
- const array = ['IN', 'NIN', 'BTW'].includes(operator);
298
+ const array = takesListValue(operator);
291
299
  if (value === undefined || (!array && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(operator))) fail('Expected a scalar filter value');
292
300
  if (array && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
293
301
  if (array && !Array.isArray(value)) fail(`${operator} requires an array value`);
294
302
  if (operator === 'BTW' && value.length !== 2) fail('BTW requires two values');
295
- if (textQueryOperator(operator)) validateTextFilterValue(operator, value, resolved.steps.at(-1).scalar, entry.path);
303
+ if (operator === 'CONTAINS_ALL') validateContainsAllValue(value);
304
+ if (textQueryOperator(operator)) validateTextFilterValue(operator, value, scalar, entry.path);
305
+ if (operator === 'IIN') validateTextListFilterValue(operator, value, scalar, entry.path);
296
306
  };
297
307
  /** Internal: check sort shape and path syntax without resolving or authorizing model fields. */
298
308
  export const assertSortSyntax = (sort) => {
@@ -407,6 +417,32 @@ export const validateQuantifiedGroups = (args, ctx) => {
407
417
  if (entry.form === 'quantified') {
408
418
  if (!implicit && (steps.slice(0, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind))
409
419
  || !['reference', 'collection', 'embeddedList'].includes(last.kind))) fail('A quantified path must end at a relation without crossing a list', 'INVALID_FILTER_PATH');
420
+ // A hand-built anchor (#108 part 4) is first a non-empty array of single path segments, before the path rule,
421
+ // which counts the anchor's steps by its elements; list() reads any other present value as [].
422
+ const anchored = implicit && entry.construct.__anchor != null;
423
+ if (anchored && (!anchor.length || anchor.some((name) => typeof name !== 'string' || name.includes('.')))) {
424
+ fail('An implicit quantified anchor must name the aggregate\'s join prefixes', 'INVALID_FILTER_PATH');
425
+ }
426
+ // A hand-built implicit group, whatever its operators (#108 part 4): after any anchor, its path ends at a collection,
427
+ // whatever it crosses first, or at a reference without crossing a list, the explicit groups' rule for references.
428
+ if (implicit && last.kind !== 'collection' && (last.kind !== 'reference'
429
+ || steps.slice(anchor.length, -1).some((step) => ['collection', 'embeddedList'].includes(step.kind)))) {
430
+ fail('An implicit quantified path must end at a collection, or at a reference without crossing a list', 'INVALID_FILTER_PATH');
431
+ }
432
+ // Then the shape the rewrite writes (cutAt): in an aggregate, on a group at the root, an anchor that crosses at
433
+ // least one collection, every one a J prefix, with any embedded lists, embedded objects and references between,
434
+ // and after it one step, a collection.
435
+ if (anchored) {
436
+ const prefixes = ctx.J ?? new Set();
437
+ const crossed = steps.slice(0, anchor.length);
438
+ const prefix = (index) => crossed.slice(0, index + 1).map(({ name }) => name).join('.');
439
+ if (ctx.operation !== 'aggregate' || entry.frames.length
440
+ || !crossed.some((step) => step.kind === 'collection')
441
+ || crossed.some((step, index) => step.kind === 'collection' && !prefixes.has(prefix(index)))
442
+ || partsOf(entry.construct.path).length !== 1 || last.kind !== 'collection') {
443
+ fail('An implicit quantified anchor must name the aggregate\'s join prefixes', 'INVALID_FILTER_PATH');
444
+ }
445
+ }
410
446
  if (!implicit && !QUANTIFIERS.has(entry.quantifier)) fail('Unknown quantifier', 'INVALID_FILTER_VALUE');
411
447
  if (!implicit) gates.push(() => {
412
448
  if (ctx.quantifiers !== true || last.kind === 'embeddedList') fail('Quantified groups are not supported by this query compiler', 'UNSUPPORTED_QUERY');
@@ -420,11 +456,10 @@ export const validateQuantifiedGroups = (args, ctx) => {
420
456
  if (!valueLeaf(last, current)) fail(ordinaryCondition && steps.length === 1
421
457
  ? `Filter on object field "${last.name}" requires a path` : 'Filter on object field requires a scalar path',
422
458
  steps.length > 1 ? 'INVALID_FILTER_PATH' : 'MISSING_FILTER_PATH');
423
- // CONTAINS_ALL has no semantics in this release; its excluded shorthand form has
424
- // the permanent path error specified for it, while every other use stays unknown.
425
- if (entry.operator === 'CONTAINS_ALL' && steps.slice(0, -1).some((step) => step.kind === 'collection')) fail('The shorthand excludes them; use an explicit group', 'INVALID_FILTER_PATH');
426
459
  if (!isQueryOperator(entry.operator)) fail(`Unknown filter operator: ${entry.operator}`, 'INVALID_FILTER_OPERATOR');
427
460
  if (ctx.operators && !ctx.operators.has(entry.operator)) fail(`The ${entry.operator} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
461
+ // CONTAINS_ALL's permanent path rules, then its leaf rule (#108 section 3.3), on the frame-relative path.
462
+ if (entry.operator === 'CONTAINS_ALL') assertContainsAllPath(steps);
428
463
  if (current.gqltype && last.kind === 'abstract' && entry.value === undefined) fail(`Expected a scalar value for ${entry.path}`);
429
464
  validateValue(entry, resolved, current);
430
465
  }