@simtlix/simfinity-core 3.4.1 → 3.5.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 +1 -1
- package/package.json +10 -2
- package/src/auth/index.js +49 -1
- package/src/query-limits.js +2 -0
- package/src/query-plan.js +54 -0
- package/src/runtime.js +323 -12
- package/src/scalars/email.js +7 -0
- package/src/scalars.js +2 -2
- package/src/validators.js +2 -3
- package/types/index.d.ts +2 -0
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
|
|
|
15
15
|
- `use`, `registerMutation`, and `saveObject` for middleware and write orchestration;
|
|
16
16
|
- `preventCreatingCollection` to pass validation-only storage setup to an adapter.
|
|
17
17
|
|
|
18
|
-
The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration.
|
|
18
|
+
The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize it.
|
|
19
19
|
|
|
20
20
|
Generated and custom mutations run through `adapter.withTransaction`. Retried attempts receive fresh clones of GraphQL input objects, lists, and dates while enum values keep their identity. An adapter can define `stateValue` when its persisted enum representation differs from GraphQL enum names.
|
|
21
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "Driver-free GraphQL runtime and model metadata for Simfinity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -36,5 +36,13 @@
|
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"graphql": "^16.11.0"
|
|
39
|
-
}
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"simfinity",
|
|
42
|
+
"graphql",
|
|
43
|
+
"runtime",
|
|
44
|
+
"schema",
|
|
45
|
+
"crud",
|
|
46
|
+
"nodejs"
|
|
47
|
+
]
|
|
40
48
|
}
|
package/src/auth/index.js
CHANGED
|
@@ -32,9 +32,11 @@
|
|
|
32
32
|
* const yoga = createYoga({ schema, plugins: [authPlugin] });
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { GraphQLObjectType, defaultFieldResolver } from 'graphql';
|
|
35
|
+
import { GraphQLError, GraphQLObjectType, defaultFieldResolver } from 'graphql';
|
|
36
|
+
import SimfinityError from '../errors/simfinity.error.js';
|
|
36
37
|
import { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
|
|
37
38
|
import { isPolicyExpression, createRuleFromExpression, evaluateExpression } from './expressions.js';
|
|
39
|
+
import { collectQueryPaths, walkQueryPath } from '../query-plan.js';
|
|
38
40
|
import {
|
|
39
41
|
resolvePath,
|
|
40
42
|
requireAuth,
|
|
@@ -195,6 +197,46 @@ const executeRule = async (rule, parent, args, ctx, info) => {
|
|
|
195
197
|
return false;
|
|
196
198
|
};
|
|
197
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Applies read rules to the paths a client names in a generated list, aggregate or collection
|
|
202
|
+
* field's filter, sort and aggregation arguments. Each segment needs its own `Type.field` rule
|
|
203
|
+
* (exact, wildcard, then default policy). Rules run without a parent, with empty args and the
|
|
204
|
+
* path field's identity in info, so parent-dependent rules such as isOwner deny query paths. A rule that
|
|
205
|
+
* throws anything other than a Simfinity or GraphQL error denies the path.
|
|
206
|
+
*/
|
|
207
|
+
const authorizeQueryPaths = async (permissions, defaultPolicy, schema, query, args, ctx, info) => {
|
|
208
|
+
const queriedType = query && schema?.getType?.(query.typeName);
|
|
209
|
+
if (!queriedType) return;
|
|
210
|
+
const checked = new Set();
|
|
211
|
+
for (const segments of collectQueryPaths(args, query.operation)) {
|
|
212
|
+
const steps = [];
|
|
213
|
+
walkQueryPath(queriedType, segments, (step) => steps.push(step));
|
|
214
|
+
for (const { type, fieldName, field } of steps) {
|
|
215
|
+
const key = `${type.name}.${fieldName}`;
|
|
216
|
+
if (checked.has(key)) continue;
|
|
217
|
+
checked.add(key);
|
|
218
|
+
const rules = getFieldRules(permissions, type.name, fieldName);
|
|
219
|
+
if (rules === null) {
|
|
220
|
+
if (defaultPolicy === 'DENY') throw new ForbiddenError(`Access denied to ${key}`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// The operation and response location belong to the invoking query, but permissions must
|
|
224
|
+
// see the identity of the path field, just as when that field is selected directly.
|
|
225
|
+
const fieldInfo = { ...info, fieldName, parentType: type, returnType: field.type };
|
|
226
|
+
for (const rule of rules) {
|
|
227
|
+
let allowed;
|
|
228
|
+
try {
|
|
229
|
+
allowed = await executeRule(rule, undefined, {}, ctx, fieldInfo);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof SimfinityError || error instanceof GraphQLError) throw error;
|
|
232
|
+
allowed = false;
|
|
233
|
+
}
|
|
234
|
+
if (!allowed) throw new ForbiddenError(`Access denied to ${key}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
198
240
|
/**
|
|
199
241
|
* Creates a graphql-middleware compatible authorization middleware.
|
|
200
242
|
*
|
|
@@ -227,6 +269,7 @@ export const createAuthMiddleware = (permissions, options = {}) => {
|
|
|
227
269
|
|
|
228
270
|
// Get rules for this field
|
|
229
271
|
const rules = getFieldRules(permissions, typeName, fieldName);
|
|
272
|
+
const query = info.parentType?.getFields?.()[fieldName]?.extensions?.simfinityQuery;
|
|
230
273
|
|
|
231
274
|
// If no rules found, apply default policy
|
|
232
275
|
if (rules === null) {
|
|
@@ -237,6 +280,7 @@ export const createAuthMiddleware = (permissions, options = {}) => {
|
|
|
237
280
|
}
|
|
238
281
|
|
|
239
282
|
// ALLOW - proceed to resolver
|
|
283
|
+
await authorizeQueryPaths(permissions, defaultPolicy, info.schema, query, args, ctx, info);
|
|
240
284
|
return resolve(parent, args, ctx, info);
|
|
241
285
|
}
|
|
242
286
|
|
|
@@ -252,6 +296,7 @@ export const createAuthMiddleware = (permissions, options = {}) => {
|
|
|
252
296
|
}
|
|
253
297
|
}
|
|
254
298
|
|
|
299
|
+
await authorizeQueryPaths(permissions, defaultPolicy, info.schema, query, args, ctx, info);
|
|
255
300
|
log(`Access granted to ${typeName}.${fieldName}`);
|
|
256
301
|
|
|
257
302
|
// All rules passed - proceed to resolver
|
|
@@ -344,6 +389,7 @@ export const createAuthPlugin = (permissions, options = {}) => {
|
|
|
344
389
|
for (const [fieldName, field] of Object.entries(fields)) {
|
|
345
390
|
const rules = getFieldRules(permissions, typeName, fieldName);
|
|
346
391
|
const originalResolve = field.resolve || defaultFieldResolver;
|
|
392
|
+
const query = field.extensions?.simfinityQuery;
|
|
347
393
|
|
|
348
394
|
field.resolve = async (parent, args, ctx, info) => {
|
|
349
395
|
log(`Checking ${typeName}.${fieldName}`);
|
|
@@ -355,6 +401,7 @@ export const createAuthPlugin = (permissions, options = {}) => {
|
|
|
355
401
|
throw new ForbiddenError(`Access denied to ${typeName}.${fieldName}`);
|
|
356
402
|
}
|
|
357
403
|
|
|
404
|
+
await authorizeQueryPaths(permissions, defaultPolicy, schema, query, args, ctx, info);
|
|
358
405
|
return originalResolve(parent, args, ctx, info);
|
|
359
406
|
}
|
|
360
407
|
|
|
@@ -369,6 +416,7 @@ export const createAuthPlugin = (permissions, options = {}) => {
|
|
|
369
416
|
}
|
|
370
417
|
}
|
|
371
418
|
|
|
419
|
+
await authorizeQueryPaths(permissions, defaultPolicy, schema, query, args, ctx, info);
|
|
372
420
|
log(`Access granted to ${typeName}.${fieldName}`);
|
|
373
421
|
|
|
374
422
|
return originalResolve(parent, args, ctx, info);
|
package/src/query-limits.js
CHANGED
|
@@ -14,6 +14,8 @@ export const configureQueryLimits = (options = {}) => {
|
|
|
14
14
|
queryMaxPageSize = maxPageSize;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
export const getQueryMaxPageSize = () => queryMaxPageSize;
|
|
18
|
+
|
|
17
19
|
export const paginationStages = (pagination, withDefault) => {
|
|
18
20
|
if (pagination == null) return withDefault ? [{ $skip: 0 }, { $limit: Math.min(100, queryMaxPageSize) }] : [];
|
|
19
21
|
const { page, size } = pagination;
|
package/src/query-plan.js
CHANGED
|
@@ -4,6 +4,60 @@ import SimfinityError from './errors/simfinity.error.js';
|
|
|
4
4
|
const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
|
|
5
5
|
const operators = new Set(['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE']);
|
|
6
6
|
const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
|
|
7
|
+
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Internal: the model paths named by generated query arguments, as `{ segments, group }` entries.
|
|
11
|
+
* Covers filter keys and relation terms, AND/OR conditions, list sort terms, and aggregation
|
|
12
|
+
* groupId and fact paths; aggregate sort terms name result keys. `group` is the AND/OR group
|
|
13
|
+
* object whose `conditions` hold the path, or null for the other forms. Malformed entries are
|
|
14
|
+
* left to the backends.
|
|
15
|
+
*/
|
|
16
|
+
export const collectQueryPathEntries = (args, operation = 'find') => {
|
|
17
|
+
const entries = [];
|
|
18
|
+
const add = (path, group = null) => {
|
|
19
|
+
if (typeof path === 'string' && path) entries.push({ segments: path.split('.'), group });
|
|
20
|
+
};
|
|
21
|
+
if (!args || typeof args !== 'object') return entries;
|
|
22
|
+
const visit = (group) => {
|
|
23
|
+
if (!group || typeof group !== 'object') return;
|
|
24
|
+
for (const condition of listOf(group.conditions)) {
|
|
25
|
+
if (typeof condition?.field !== 'string') continue;
|
|
26
|
+
add(condition.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition.field, group);
|
|
27
|
+
}
|
|
28
|
+
for (const item of [...listOf(group.AND), ...listOf(group.OR)]) visit(item);
|
|
29
|
+
};
|
|
30
|
+
for (const [name, value] of Object.entries(args)) {
|
|
31
|
+
if (reserved.has(name) || value == null) continue;
|
|
32
|
+
const terms = listOf(value.terms).filter((term) => typeof term?.path === 'string' && term.path);
|
|
33
|
+
if (terms.length) for (const term of terms) add(`${name}.${term.path}`);
|
|
34
|
+
else add(name);
|
|
35
|
+
}
|
|
36
|
+
for (const item of [...listOf(args.AND), ...listOf(args.OR)]) visit(item);
|
|
37
|
+
if (operation === 'aggregate') {
|
|
38
|
+
add(args.aggregation?.groupId);
|
|
39
|
+
for (const fact of listOf(args.aggregation?.facts)) add(fact?.path);
|
|
40
|
+
} else for (const term of listOf(args.sort?.terms)) add(term?.field);
|
|
41
|
+
return entries;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Internal: the segment arrays of {@link collectQueryPathEntries}. */
|
|
45
|
+
export const collectQueryPaths = (args, operation = 'find') => (
|
|
46
|
+
collectQueryPathEntries(args, operation).map((entry) => entry.segments)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/** Internal: visits each declared GraphQL field a path names, stopping at an unknown or leaf segment. */
|
|
50
|
+
export const walkQueryPath = (gqltype, segments, visit) => {
|
|
51
|
+
let type = gqltype;
|
|
52
|
+
for (const [index, fieldName] of segments.entries()) {
|
|
53
|
+
const fields = typeof type?.getFields === 'function' ? type.getFields() : null;
|
|
54
|
+
if (!fields || !Object.hasOwn(fields, fieldName)) return;
|
|
55
|
+
const field = fields[fieldName];
|
|
56
|
+
visit({ type, fieldName, field, index });
|
|
57
|
+
type = field.type;
|
|
58
|
+
while (type?.ofType) type = type.ofType;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
7
61
|
|
|
8
62
|
export const resolveModelPath = (models, entityName, path) => {
|
|
9
63
|
const parts = Array.isArray(path) ? path : typeof path === 'string' ? path.split('.') : [];
|
package/src/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { configureQueryLimits } from './query-limits.js';
|
|
1
|
+
import { configureQueryLimits, getQueryMaxPageSize } from './query-limits.js';
|
|
2
2
|
import {
|
|
3
3
|
GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
|
|
4
4
|
GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
|
|
@@ -10,8 +10,66 @@ import InternalServerError from './errors/internal-server.error.js';
|
|
|
10
10
|
import QLOperator from './const/QLOperator.js';
|
|
11
11
|
import QLValue from './const/QLValue.js';
|
|
12
12
|
import QLSort from './const/QLSort.js';
|
|
13
|
+
import { collectQueryPathEntries, collectQueryPaths, walkQueryPath } from './query-plan.js';
|
|
13
14
|
import './introspection.js';
|
|
14
15
|
|
|
16
|
+
// Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
|
|
17
|
+
// created is application code, such as a masking resolver, recorded once per type so later
|
|
18
|
+
// in-place wrapping (for example by the auth plugin) does not change the snapshot.
|
|
19
|
+
const generatedResolvers = new WeakSet();
|
|
20
|
+
const applicationResolvedFields = new WeakMap();
|
|
21
|
+
const markGenerated = (resolve) => {
|
|
22
|
+
generatedResolvers.add(resolve);
|
|
23
|
+
return resolve;
|
|
24
|
+
};
|
|
25
|
+
const listApplicationResolvedFields = (gqltype) => new Set(Object.entries(gqltype.getFields())
|
|
26
|
+
.filter(([, field]) => field.resolve && !generatedResolvers.has(field.resolve))
|
|
27
|
+
.map(([fieldName]) => fieldName));
|
|
28
|
+
const hasApplicationResolver = (gqltype, fieldName) => (
|
|
29
|
+
applicationResolvedFields.get(gqltype) || listApplicationResolvedFields(gqltype)
|
|
30
|
+
).has(fieldName);
|
|
31
|
+
const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER_PATH', 403);
|
|
32
|
+
const SCOPE_IGNORED_ARGS = new Set(['sort', 'pagination', 'aggregation']);
|
|
33
|
+
|
|
34
|
+
// Moves a joined type's scope group below the relation path that entered it.
|
|
35
|
+
const prefixScopeGroup = (group, prefix) => {
|
|
36
|
+
if (!group || typeof group !== 'object' || Array.isArray(group)) {
|
|
37
|
+
throw new SimfinityError('Expected a scope filter group object', 'INVALID_FILTER_VALUE', 400);
|
|
38
|
+
}
|
|
39
|
+
const prefixed = {};
|
|
40
|
+
if (group.conditions != null) {
|
|
41
|
+
prefixed.conditions = [].concat(group.conditions).map((condition) => ({
|
|
42
|
+
field: `${prefix}.${condition?.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition?.field}`,
|
|
43
|
+
operator: condition?.operator,
|
|
44
|
+
value: condition?.value,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix));
|
|
48
|
+
if (group.OR != null) prefixed.OR = [].concat(group.OR).map((item) => prefixScopeGroup(item, prefix));
|
|
49
|
+
return prefixed;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
53
|
+
const conditions = [];
|
|
54
|
+
const groups = [];
|
|
55
|
+
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
56
|
+
if (value == null || SCOPE_IGNORED_ARGS.has(name)) continue;
|
|
57
|
+
if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix)));
|
|
58
|
+
else if (name === 'OR') {
|
|
59
|
+
const branches = [].concat(value);
|
|
60
|
+
if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix)) });
|
|
61
|
+
} else if (Object.hasOwn(value, 'terms')) {
|
|
62
|
+
if (!Array.isArray(value.terms) || !value.terms.length) {
|
|
63
|
+
throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
|
|
64
|
+
}
|
|
65
|
+
for (const term of value.terms) {
|
|
66
|
+
conditions.push({ field: `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value });
|
|
67
|
+
}
|
|
68
|
+
} else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value });
|
|
69
|
+
}
|
|
70
|
+
return conditions.length ? [{ conditions }, ...groups] : groups;
|
|
71
|
+
};
|
|
72
|
+
|
|
15
73
|
const GraphQLJSON = new GraphQLScalarType({
|
|
16
74
|
name: 'JSON',
|
|
17
75
|
description: 'The `JSON` scalar type represents JSON values as specified by ECMA-404',
|
|
@@ -684,6 +742,42 @@ const getEmbeddedFieldNames = (gqltype) => {
|
|
|
684
742
|
return names;
|
|
685
743
|
};
|
|
686
744
|
|
|
745
|
+
// Update inputs relax embedded NonNull members, so check the embedded values an update writes
|
|
746
|
+
// against the embedded output type. Omitted lists are written as [], as on create. With `patch`,
|
|
747
|
+
// only nested embedded values supplied by that patch are checked; stored nested values it keeps
|
|
748
|
+
// are not. `id` and readOnly members are not accepted by generated inputs and remain the
|
|
749
|
+
// application's responsibility.
|
|
750
|
+
const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
|
|
751
|
+
const listShape = getListShape(fieldEntry.type);
|
|
752
|
+
const embeddedType = listShape ? listShape.itemType : unwrapNonNull(fieldEntry.type);
|
|
753
|
+
let items = [value];
|
|
754
|
+
if (listShape) items = Array.isArray(value) ? value : [];
|
|
755
|
+
for (const item of items) {
|
|
756
|
+
if (item === null || item === undefined) {
|
|
757
|
+
if (listShape && listShape.itemNonNull) {
|
|
758
|
+
throw new SimfinityError(`Required value ${fieldName}[] is missing`, 'REQUIRED_VALUE', 400);
|
|
759
|
+
}
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
for (const [memberName, member] of Object.entries(embeddedType.getFields())) {
|
|
763
|
+
const relation = member.extensions?.relation;
|
|
764
|
+
const memberList = getListShape(member.type);
|
|
765
|
+
if (relation && !relation.embedded && memberList) continue;
|
|
766
|
+
const storageName = getFieldStorageName(memberName, member);
|
|
767
|
+
if (item[storageName] === undefined && memberList) item[storageName] = [];
|
|
768
|
+
if (memberName === 'id' || member.extensions?.readOnly) continue;
|
|
769
|
+
const memberValue = item[storageName];
|
|
770
|
+
if (memberValue === null || memberValue === undefined) {
|
|
771
|
+
if (member.type instanceof GraphQLNonNull) {
|
|
772
|
+
throw new SimfinityError(`Required value ${memberName} is missing`, 'REQUIRED_VALUE', 400);
|
|
773
|
+
}
|
|
774
|
+
} else if (relation && relation.embedded && (!patch || Object.hasOwn(patch, storageName))) {
|
|
775
|
+
completeEmbeddedValue(memberName, member, memberValue);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
|
|
687
781
|
const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
|
|
688
782
|
const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
|
|
689
783
|
const objectId = args.id;
|
|
@@ -704,8 +798,11 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
704
798
|
if (newObjectData) {
|
|
705
799
|
if (Array.isArray(newObjectData)) {
|
|
706
800
|
materializedModel.modelArgs[fieldEntryName] = newObjectData;
|
|
801
|
+
completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName], newObjectData);
|
|
707
802
|
} else {
|
|
708
803
|
materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
|
|
804
|
+
completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName],
|
|
805
|
+
materializedModel.modelArgs[fieldEntryName], newObjectData);
|
|
709
806
|
}
|
|
710
807
|
}
|
|
711
808
|
}
|
|
@@ -921,9 +1018,203 @@ const executeScope = async (params) => {
|
|
|
921
1018
|
return scopeFunction({ type, args, operation, context });
|
|
922
1019
|
};
|
|
923
1020
|
|
|
1021
|
+
// Copies AND/OR group lists with new group objects and arrays, leaving conditions and other values
|
|
1022
|
+
// shared.
|
|
1023
|
+
const copyFilterGroups = (groups) => (Array.isArray(groups) ? groups.map((group) => {
|
|
1024
|
+
if (!group || typeof group !== 'object' || Array.isArray(group)) return group;
|
|
1025
|
+
const copy = { ...group };
|
|
1026
|
+
if (Array.isArray(group.conditions)) copy.conditions = [...group.conditions];
|
|
1027
|
+
if (Object.hasOwn(group, 'AND')) copy.AND = copyFilterGroups(group.AND);
|
|
1028
|
+
if (Object.hasOwn(group, 'OR')) copy.OR = copyFilterGroups(group.OR);
|
|
1029
|
+
return copy;
|
|
1030
|
+
}) : groups);
|
|
1031
|
+
|
|
1032
|
+
// Checks the filter, sort and aggregation paths a client supplied, before middleware and scopes
|
|
1033
|
+
// add trusted paths. Returns the scoped relations those paths enter, keyed by path, with the
|
|
1034
|
+
// client AND/OR groups whose conditions use each path, or `topLevel` for every other form.
|
|
1035
|
+
// graphql-js passes the same variable values to every resolver call of a request, so the client's
|
|
1036
|
+
// AND/OR groups are first copied onto this call's args, and joined scopes are only placed in those copies.
|
|
1037
|
+
const inspectClientPaths = (gqltype, args, operation) => {
|
|
1038
|
+
for (const key of ['AND', 'OR']) {
|
|
1039
|
+
if (Object.hasOwn(args, key)) args[key] = copyFilterGroups(args[key]);
|
|
1040
|
+
}
|
|
1041
|
+
const joinedScopes = new Map();
|
|
1042
|
+
for (const { segments, group } of collectQueryPathEntries(args, operation)) {
|
|
1043
|
+
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
1044
|
+
const path = segments.slice(0, index + 1).join('.');
|
|
1045
|
+
const queryable = field.extensions?.queryable;
|
|
1046
|
+
if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
|
|
1047
|
+
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
1048
|
+
}
|
|
1049
|
+
const relation = field.extensions?.relation;
|
|
1050
|
+
if (!relation || relation.embedded) return;
|
|
1051
|
+
const target = typesDict.types[unwrapListAndNonNull(field.type).name];
|
|
1052
|
+
if (typeof target?.gqltype?.extensions?.scope?.find !== 'function') return;
|
|
1053
|
+
if (!joinedScopes.has(path)) {
|
|
1054
|
+
joinedScopes.set(path, {
|
|
1055
|
+
target,
|
|
1056
|
+
collection: getListShape(field.type) ? `${type.name}.${fieldName}` : null,
|
|
1057
|
+
topLevel: false,
|
|
1058
|
+
groups: new Set(),
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
const joined = joinedScopes.get(path);
|
|
1062
|
+
if (group) joined.groups.add(group);
|
|
1063
|
+
else joined.topLevel = true;
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
return joinedScopes;
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
// A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
|
|
1070
|
+
// reach a non-embedded relation inside an embedded list, which is joined once per list item.
|
|
1071
|
+
const assertJoinedScopePaths = (groups, prefix, target) => {
|
|
1072
|
+
const depth = prefix.split('.').length;
|
|
1073
|
+
for (const segments of collectQueryPaths({ AND: groups })) {
|
|
1074
|
+
let inList = false;
|
|
1075
|
+
walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field }) => {
|
|
1076
|
+
const relation = field.extensions?.relation;
|
|
1077
|
+
const list = !!getListShape(field.type);
|
|
1078
|
+
if (relation && !relation.embedded && (list || inList)) {
|
|
1079
|
+
throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope filters through `
|
|
1080
|
+
+ `${list ? 'the collection' : 'the relation'} ${type.name}.${fieldName}${list ? '' : ' inside a list'}`);
|
|
1081
|
+
}
|
|
1082
|
+
inList ||= list;
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
const appendAndGroups = (owner, groups) => {
|
|
1088
|
+
if (owner.AND != null && !Array.isArray(owner.AND)) {
|
|
1089
|
+
throw new SimfinityError('AND requires an array', 'INVALID_FILTER_VALUE', 400);
|
|
1090
|
+
}
|
|
1091
|
+
owner.AND = [...(owner.AND || []), ...groups];
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
// The deepest AND/OR group nesting the backends accept; groups listed in args.AND/OR are at depth 0.
|
|
1095
|
+
const MAX_FILTER_GROUP_DEPTH = 5;
|
|
1096
|
+
const nestedGroups = (group) => [...[].concat(group.AND ?? []), ...[].concat(group.OR ?? [])];
|
|
1097
|
+
const groupDepth = (group) => Math.max(0, ...nestedGroups(group)
|
|
1098
|
+
.filter((item) => item && typeof item === 'object').map((item) => groupDepth(item) + 1));
|
|
1099
|
+
|
|
1100
|
+
// Maps each AND/OR group of the query to the deepest level it appears at.
|
|
1101
|
+
const collectFilterGroups = (args) => {
|
|
1102
|
+
const found = new Map();
|
|
1103
|
+
const visit = (group, depth) => {
|
|
1104
|
+
if (!group || typeof group !== 'object' || found.get(group) >= depth) return;
|
|
1105
|
+
found.set(group, depth);
|
|
1106
|
+
if (depth <= MAX_FILTER_GROUP_DEPTH) for (const item of nestedGroups(group)) visit(item, depth + 1);
|
|
1107
|
+
};
|
|
1108
|
+
for (const item of nestedGroups(args)) visit(item, 0);
|
|
1109
|
+
return found;
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
// Restricts referenced records reached by client paths with each target type's find scope. A path
|
|
1113
|
+
// used only in AND/OR group conditions restricts just those groups, so an OR alternative that does
|
|
1114
|
+
// not use it keeps its matches; any other use, a group that middleware or a scope detached, or a
|
|
1115
|
+
// group too deep to hold the scope groups within the backends' depth limit, restricts the whole query.
|
|
1116
|
+
const applyJoinedScopes = async (joinedScopes, params) => {
|
|
1117
|
+
let queryGroups;
|
|
1118
|
+
for (const [prefix, {
|
|
1119
|
+
target, collection, topLevel, groups: owners,
|
|
1120
|
+
}] of joinedScopes) {
|
|
1121
|
+
const scopeArgs = {};
|
|
1122
|
+
await executeScope({ type: target, args: scopeArgs, operation: 'find', context: params.context });
|
|
1123
|
+
const groups = prefixScopeArgs(scopeArgs, prefix, target.gqltype.name);
|
|
1124
|
+
if (!groups.length) continue;
|
|
1125
|
+
if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
|
|
1126
|
+
assertJoinedScopePaths(groups, prefix, target);
|
|
1127
|
+
queryGroups ??= collectFilterGroups(params.args);
|
|
1128
|
+
const addedDepth = 1 + Math.max(...groups.map(groupDepth));
|
|
1129
|
+
if (topLevel || [...owners].some((owner) => !queryGroups.has(owner)
|
|
1130
|
+
|| queryGroups.get(owner) + addedDepth > MAX_FILTER_GROUP_DEPTH)) appendAndGroups(params.args, groups);
|
|
1131
|
+
else for (const owner of owners) appendAndGroups(owner, groups);
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
// Unscoped relation reads of one request, grouped by related type until the promise jobs queued so
|
|
1136
|
+
// far have run, and keyed by cast ID. Nothing is cached: each batch is read once and dropped.
|
|
1137
|
+
const referenceBatches = new WeakMap();
|
|
1138
|
+
|
|
1139
|
+
const readReferences = async (type, keys, batch, context) => {
|
|
1140
|
+
let found = new Map();
|
|
1141
|
+
try {
|
|
1142
|
+
const records = await adapter.getByIds(type.model, keys.map((key) => batch.get(key)[0].id), { context });
|
|
1143
|
+
for (const record of records) {
|
|
1144
|
+
if (record != null) found.set(String(adapter.castId(record._id ?? record.id)), record);
|
|
1145
|
+
}
|
|
1146
|
+
} catch {
|
|
1147
|
+
found = null;
|
|
1148
|
+
}
|
|
1149
|
+
// After a failed batch, each ID is read alone, so an error fails only the fields that use it.
|
|
1150
|
+
const reads = new Map();
|
|
1151
|
+
for (const key of keys) {
|
|
1152
|
+
for (const { id, requiredId, resolve, reject } of batch.get(key)) {
|
|
1153
|
+
if (found) {
|
|
1154
|
+
resolve(found.get(key) ?? null);
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
if (!reads.has(id)) {
|
|
1158
|
+
reads.set(id, Promise.resolve().then(() => adapter.getById(type.model, id, null, { requiredId, context })));
|
|
1159
|
+
}
|
|
1160
|
+
reads.get(id).then(resolve, reject);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
const flushReferences = (type, batch, context) => {
|
|
1166
|
+
const keys = [...batch.keys()];
|
|
1167
|
+
const size = getQueryMaxPageSize();
|
|
1168
|
+
for (let start = 0; start < keys.length; start += size) {
|
|
1169
|
+
readReferences(type, keys.slice(start, start + size), batch, context);
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
const loadReference = (type, id, requiredId, context) => {
|
|
1174
|
+
let key;
|
|
1175
|
+
try {
|
|
1176
|
+
key = String(adapter.castId(id));
|
|
1177
|
+
} catch {
|
|
1178
|
+
// An ID the adapter cannot cast is read alone, so only its field reports the error.
|
|
1179
|
+
return adapter.getById(type.model, id, null, { requiredId, context });
|
|
1180
|
+
}
|
|
1181
|
+
// Casting may normalize (e.g. ObjectId hex case), so non-canonical IDs are read alone too.
|
|
1182
|
+
if (key !== String(id)) return adapter.getById(type.model, id, null, { requiredId, context });
|
|
1183
|
+
return new Promise((resolve, reject) => {
|
|
1184
|
+
let batches = referenceBatches.get(context);
|
|
1185
|
+
if (!batches) {
|
|
1186
|
+
batches = new Map();
|
|
1187
|
+
referenceBatches.set(context, batches);
|
|
1188
|
+
}
|
|
1189
|
+
let batch = batches.get(type);
|
|
1190
|
+
if (!batch) {
|
|
1191
|
+
batch = new Map();
|
|
1192
|
+
batches.set(type, batch);
|
|
1193
|
+
// As DataLoader does, flush once the promise jobs queued so far have run, so sibling calls
|
|
1194
|
+
// that awaited middleware without I/O join the same batch.
|
|
1195
|
+
Promise.resolve().then(() => process.nextTick(() => {
|
|
1196
|
+
batches.delete(type);
|
|
1197
|
+
flushReferences(type, batch, context);
|
|
1198
|
+
}));
|
|
1199
|
+
}
|
|
1200
|
+
if (!batch.has(key)) batch.set(key, []);
|
|
1201
|
+
batch.get(key).push({
|
|
1202
|
+
id, requiredId, resolve, reject,
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
// `requiredId` marks a generated single-reference read. Middleware still runs per call; then, when the
|
|
1208
|
+
// adapter reads by ID in batches, and unless the type has a get_by_id scope or middleware changed the
|
|
1209
|
+
// ID, the read joins the request's batch.
|
|
924
1210
|
const resolveById = async (type, args, context, requiredId) => {
|
|
1211
|
+
const requestedId = args.id;
|
|
925
1212
|
await executeMiddleware({ type, args, operation: 'get_by_id', context });
|
|
926
1213
|
if (!type.gqltype.extensions?.scope?.get_by_id) {
|
|
1214
|
+
if (requiredId != null && args.id === requestedId && typeof context === 'object' && context !== null
|
|
1215
|
+
&& typeof adapter.getByIds === 'function' && type.gqltype.getFields().id) {
|
|
1216
|
+
return loadReference(type, args.id, requiredId, context);
|
|
1217
|
+
}
|
|
927
1218
|
return adapter.getById(type.model, args.id, null, { requiredId, context });
|
|
928
1219
|
}
|
|
929
1220
|
const queryArgs = { id: { operator: 'EQ', value: args.id } };
|
|
@@ -1069,7 +1360,7 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1069
1360
|
const wasAddedAsNoEnpointType = !type.simpleEntityEndpointName;
|
|
1070
1361
|
if (!wasAddedAsNoEnpointType) {
|
|
1071
1362
|
if (type.gqltype.getFields().id && !type.gqltype.getFields().id.resolve) {
|
|
1072
|
-
type.gqltype.getFields().id.resolve = (parent) => parent._id;
|
|
1363
|
+
type.gqltype.getFields().id.resolve = markGenerated((parent) => parent._id);
|
|
1073
1364
|
}
|
|
1074
1365
|
|
|
1075
1366
|
rootQueryArgs.fields[type.simpleEntityEndpointName] = {
|
|
@@ -1087,7 +1378,9 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1087
1378
|
rootQueryArgs.fields[type.listEntitiesEndpointName] = {
|
|
1088
1379
|
type: new GraphQLList(type.gqltype),
|
|
1089
1380
|
args: argsObject,
|
|
1381
|
+
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find' } },
|
|
1090
1382
|
async resolve(parent, args, context) {
|
|
1383
|
+
const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
|
|
1091
1384
|
const params = {
|
|
1092
1385
|
type,
|
|
1093
1386
|
args,
|
|
@@ -1096,11 +1389,13 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1096
1389
|
};
|
|
1097
1390
|
await executeMiddleware(params);
|
|
1098
1391
|
await executeScope(params);
|
|
1099
|
-
|
|
1392
|
+
await applyJoinedScopes(joinedScopes, params);
|
|
1393
|
+
const queryArgs = params.args;
|
|
1394
|
+
const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count);
|
|
1100
1395
|
|
|
1101
|
-
const dataPromise = adapter.find(type.model, type.gqltype,
|
|
1396
|
+
const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
|
|
1102
1397
|
const countPromise = wantsCount
|
|
1103
|
-
? adapter.count(type.model, type.gqltype,
|
|
1398
|
+
? adapter.count(type.model, type.gqltype, queryArgs, null)
|
|
1104
1399
|
: null;
|
|
1105
1400
|
|
|
1106
1401
|
const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
|
|
@@ -1119,7 +1414,9 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1119
1414
|
rootQueryArgs.fields[`${type.listEntitiesEndpointName}_aggregate`] = {
|
|
1120
1415
|
type: new GraphQLList(QLTypeAggregationResult),
|
|
1121
1416
|
args: aggregateArgsObject,
|
|
1417
|
+
extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
|
|
1122
1418
|
async resolve(parent, args, context) {
|
|
1419
|
+
const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
|
|
1123
1420
|
const params = {
|
|
1124
1421
|
type,
|
|
1125
1422
|
args,
|
|
@@ -1128,7 +1425,8 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1128
1425
|
};
|
|
1129
1426
|
await executeMiddleware(params);
|
|
1130
1427
|
await executeScope(params);
|
|
1131
|
-
|
|
1428
|
+
await applyJoinedScopes(joinedScopes, params);
|
|
1429
|
+
return adapter.aggregate(type.model, type.gqltype, params.args, null);
|
|
1132
1430
|
},
|
|
1133
1431
|
};
|
|
1134
1432
|
}
|
|
@@ -1185,8 +1483,15 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
1185
1483
|
}
|
|
1186
1484
|
});
|
|
1187
1485
|
|
|
1486
|
+
const query = buildRootQuery('RootQueryType', includedQueryTypes);
|
|
1487
|
+
Object.values(typesDict.types).forEach(({ gqltype }) => {
|
|
1488
|
+
if (gqltype && !applicationResolvedFields.has(gqltype)) {
|
|
1489
|
+
applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1188
1493
|
return new GraphQLSchema({
|
|
1189
|
-
query
|
|
1494
|
+
query,
|
|
1190
1495
|
mutation: buildMutation('Mutation', includedMutationTypes, includedCustomMutations),
|
|
1191
1496
|
});
|
|
1192
1497
|
};
|
|
@@ -1232,28 +1537,34 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1232
1537
|
if (connection.graphqlFieldName) delete argsObject[connection.graphqlFieldName];
|
|
1233
1538
|
|
|
1234
1539
|
fieldEntry.args = formatArgs(Object.entries(argsObject));
|
|
1235
|
-
fieldEntry.
|
|
1540
|
+
fieldEntry.extensions = {
|
|
1541
|
+
...fieldEntry.extensions,
|
|
1542
|
+
simfinityQuery: { typeName: relatedType.name, operation: 'find' },
|
|
1543
|
+
};
|
|
1544
|
+
fieldEntry.resolve = markGenerated(async (parent, args, context) => {
|
|
1236
1545
|
if (!relatedTypeInfo || !relatedTypeInfo.model) {
|
|
1237
1546
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
1238
1547
|
}
|
|
1548
|
+
const joinedScopes = inspectClientPaths(relatedType, args, 'find');
|
|
1239
1549
|
const params = { type: relatedTypeInfo, args, operation: 'find', context };
|
|
1240
1550
|
await executeMiddleware(params);
|
|
1241
1551
|
await executeScope(params);
|
|
1552
|
+
await applyJoinedScopes(joinedScopes, params);
|
|
1242
1553
|
return adapter.findChildren(
|
|
1243
1554
|
relatedTypeInfo.model,
|
|
1244
1555
|
relatedTypeInfo.gqltype,
|
|
1245
1556
|
connection.storageFieldName,
|
|
1246
1557
|
parent.id || parent._id,
|
|
1247
|
-
args,
|
|
1558
|
+
params.args,
|
|
1248
1559
|
null,
|
|
1249
1560
|
);
|
|
1250
|
-
};
|
|
1561
|
+
});
|
|
1251
1562
|
} else if (fieldEntry.type instanceof GraphQLObjectType
|
|
1252
1563
|
|| (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType)) {
|
|
1253
1564
|
const relatedType = unwrapNonNull(fieldEntry.type);
|
|
1254
1565
|
const connectionField = relation.connectionField || fieldName;
|
|
1255
1566
|
|
|
1256
|
-
fieldEntry.resolve = async (parent, args, context) => {
|
|
1567
|
+
fieldEntry.resolve = markGenerated(async (parent, args, context) => {
|
|
1257
1568
|
const relatedTypeInfo = typesDict.types[relatedType.name];
|
|
1258
1569
|
if (!relatedTypeInfo || !relatedTypeInfo.model) {
|
|
1259
1570
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
@@ -1261,7 +1572,7 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1261
1572
|
const relatedId = parent[connectionField] || parent[fieldName];
|
|
1262
1573
|
const id = relatedId?._id || relatedId;
|
|
1263
1574
|
return id ? resolveById(relatedTypeInfo, { id: String(id) }, context, id) : null;
|
|
1264
|
-
};
|
|
1575
|
+
});
|
|
1265
1576
|
}
|
|
1266
1577
|
}
|
|
1267
1578
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Accepts exactly the strings matched by /^[^\s@]+@[^\s@]+\.[^\s@]+$/ in linear time. The
|
|
2
|
+
// lookahead rejects a domain containing '@' or whitespace before the ambiguous `x.y` split is
|
|
3
|
+
// tried, so hostile input such as 'a@' + '.'.repeat(n) + ' ' cannot backtrack quadratically.
|
|
4
|
+
const EMAIL_RE = /^[^\s@]+@(?=[^\s@]*$)[^\s@]+\.[^\s@]+$/;
|
|
5
|
+
|
|
6
|
+
/** Email shape check shared by `validators.email()` and `EmailScalar`; not a published subpath. */
|
|
7
|
+
export const isEmailFormat = (value) => EMAIL_RE.test(value);
|
package/src/scalars.js
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
GraphQLString, GraphQLInt, GraphQLFloat,
|
|
3
3
|
} from 'graphql';
|
|
4
4
|
import { createValidatedScalar } from './scalars/factory.js';
|
|
5
|
+
import { isEmailFormat } from './scalars/email.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Email scalar - validates email format
|
|
@@ -12,8 +13,7 @@ export const EmailScalar = createValidatedScalar(
|
|
|
12
13
|
'A valid email address',
|
|
13
14
|
GraphQLString,
|
|
14
15
|
(value) => {
|
|
15
|
-
|
|
16
|
-
if (!emailRegex.test(value)) {
|
|
16
|
+
if (!isEmailFormat(value)) {
|
|
17
17
|
throw new Error('Invalid email format');
|
|
18
18
|
}
|
|
19
19
|
},
|
package/src/validators.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import SimfinityError from './errors/simfinity.error.js';
|
|
2
|
+
import { isEmailFormat } from './scalars/email.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Creates a validation object that works for both 'save' (CREATE) and 'update' (UPDATE) operations.
|
|
@@ -88,14 +89,12 @@ export const pattern = (name, regex, message) => {
|
|
|
88
89
|
};
|
|
89
90
|
|
|
90
91
|
export const email = () => {
|
|
91
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
92
|
-
|
|
93
92
|
return createValidator(async (typeName, fieldName, value) => {
|
|
94
93
|
if (typeof value !== 'string') {
|
|
95
94
|
throw new SimfinityError('Email must be a string', 'VALIDATION_ERROR', 400);
|
|
96
95
|
}
|
|
97
96
|
|
|
98
|
-
if (!
|
|
97
|
+
if (!isEmailFormat(value)) {
|
|
99
98
|
throw new SimfinityError('Invalid email format', 'VALIDATION_ERROR', 400);
|
|
100
99
|
}
|
|
101
100
|
}, false); // Optional
|
package/types/index.d.ts
CHANGED
|
@@ -126,6 +126,8 @@ export interface DatabaseAdapter<Model = any, Session = any> {
|
|
|
126
126
|
saveRecord(model: Model, record: any, session?: Session): any;
|
|
127
127
|
toObject(record: any): any;
|
|
128
128
|
getById(model: Model, id: any, session?: Session | null, options?: { projection?: Record<string, number>; plain?: boolean; lock?: boolean; requiredId?: any; context?: any }): any;
|
|
129
|
+
/** Optional batch read: the records found for `ids`, in getById shape and any order. After a failure, the runtime reads each ID with getById. */
|
|
130
|
+
getByIds?(model: Model, ids: any[], options?: { context?: any }): any;
|
|
129
131
|
prepareUpdate(set: Record<string, any>, unset: Record<string, string>): any;
|
|
130
132
|
update(model: Model, id: any, changes: any, session?: Session): any;
|
|
131
133
|
delete(model: Model, id: any, session?: Session): any;
|