@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.
- package/README.md +31 -4
- package/package.json +12 -2
- package/src/after-commit.js +83 -0
- package/src/auth/index.js +97 -36
- package/src/auth/operation-entries.js +111 -0
- package/src/const/QLOperator.js +2 -0
- package/src/execution-state.js +71 -0
- package/src/introspection-config.js +17 -0
- package/src/introspection-state.js +10 -0
- package/src/introspection.js +5 -3
- package/src/mutation-limits.js +37 -7
- package/src/operation-input.js +345 -0
- package/src/query-frames.js +42 -7
- package/src/query-operators.js +90 -6
- package/src/query-plan.js +15 -4
- package/src/runtime.js +722 -55
- package/src/schema-registrations.js +23 -0
- package/src/transaction-callbacks.js +34 -0
- package/src/transaction-references.js +259 -0
- package/src/transaction-schema.js +163 -0
- package/types/index.d.ts +186 -17
- package/types/introspection.d.ts +4 -0
|
@@ -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
|
+
};
|
package/src/introspection.js
CHANGED
|
@@ -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
|
package/src/mutation-limits.js
CHANGED
|
@@ -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
|
|
12
|
-
const unknown = Reflect.ownKeys(options).find((key) => key
|
|
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(
|
|
15
|
-
|
|
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 {
|
|
18
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/src/query-frames.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 =
|
|
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 (
|
|
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
|
}
|