@simtlix/simfinity-core 3.15.0 → 3.17.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,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
+ };
@@ -0,0 +1,54 @@
1
+ import SimfinityError from './errors/simfinity.error.js';
2
+
3
+ const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
4
+ const equality = new Set(['EQ', 'NE']);
5
+ const ordered = new Set(['LT', 'LTE', 'GT', 'GTE']);
6
+ const text = new Set(['LIKE', 'STARTS_WITH', 'ENDS_WITH']);
7
+ const temporal = new Set(['DateTime', 'Date', 'Time']);
8
+
9
+ /** A nullable optional path selects a field operand only when non-null and non-undefined. */
10
+ export const hasValuePath = (carrier) => carrier?.valuePath !== undefined && carrier?.valuePath !== null;
11
+
12
+ /** Validate the public operand syntax before resolving either path. Literal carriers are unchanged. */
13
+ export const validateComparisonOperand = (carrier) => {
14
+ if (!hasValuePath(carrier)) return null;
15
+ if (carrier.value !== undefined) fail('A filter cannot supply both value and valuePath');
16
+ if (typeof carrier.valuePath !== 'string') fail('valuePath requires a string field path');
17
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(carrier.valuePath)) {
18
+ fail('valuePath requires a nonempty dotted field path; outer paths require an explicit group', 'INVALID_FILTER_PATH');
19
+ }
20
+ return carrier.valuePath;
21
+ };
22
+
23
+ const typeOf = (field, path) => {
24
+ if (field.identity || field.scalar === 'ID') return 'ID';
25
+ if (field.scalar === 'Enum') {
26
+ if (!field.enumName) fail(`Field comparison on ${path} requires logical enumName metadata`, 'UNSUPPORTED_QUERY');
27
+ return `Enum:${field.enumName}`;
28
+ }
29
+ if (temporal.has(field.scalar)) {
30
+ if (!field.temporalName) fail(`Field comparison on ${path} requires effective temporalName metadata`, 'UNSUPPORTED_QUERY');
31
+ return field.temporalName;
32
+ }
33
+ return field.scalar;
34
+ };
35
+
36
+ /** Logical comparison rules shared by runtime/model validation and hand-built engine plans. */
37
+ export const validateComparisonTypes = (left, right, operator = 'EQ', leftPath = 'left', rightPath = 'right') => {
38
+ if (right.list) fail(`The right operand ${rightPath} must be a scalar, not a list`, 'INVALID_FILTER_PATH');
39
+ if (!equality.has(operator) && !ordered.has(operator) && !text.has(operator)) {
40
+ fail(`${operator} does not accept valuePath`, 'INVALID_FILTER_OPERATOR');
41
+ }
42
+ if (left.list && !equality.has(operator)) fail(`${operator} does not accept a list left operand`, 'INVALID_FILTER_OPERATOR');
43
+ const a = typeOf(left, leftPath);
44
+ const b = typeOf(right, rightPath);
45
+ const numeric = ['Int', 'Float'].includes(a) && ['Int', 'Float'].includes(b);
46
+ const supported = numeric || (a === b && (['String', 'Boolean', 'ID'].includes(a) || temporal.has(a) || a?.startsWith('Enum:')));
47
+ if (!supported || left.version || right.version) {
48
+ fail(`Incompatible field comparison: ${leftPath} (${a}) and ${rightPath} (${b})`);
49
+ }
50
+ if (text.has(operator) && a !== 'String') fail(`${operator} requires String operands`, 'INVALID_FILTER_OPERATOR');
51
+ if (ordered.has(operator) && !numeric && a !== 'String' && !temporal.has(a)) {
52
+ fail(`${operator} does not order ${a} operands`, 'INVALID_FILTER_OPERATOR');
53
+ }
54
+ };