@simtlix/simfinity-core 3.2.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,300 @@
1
+ import { UnauthenticatedError, ForbiddenError } from './errors.js';
2
+ import { normalizeObjectId } from './object-id.js';
3
+
4
+ /**
5
+ * Resolves a value from an object using a dotted path string or function.
6
+ * @param {Object} obj - The object to resolve from
7
+ * @param {string|Function} pathOrFn - Dotted path (e.g., 'user.profile.id') or function to extract value
8
+ * @returns {*} The resolved value or undefined if not found
9
+ * @example
10
+ * resolvePath({ user: { id: '123' } }, 'user.id') // returns '123'
11
+ * resolvePath({ user: { id: '123' } }, (obj) => obj.user.id) // returns '123'
12
+ */
13
+ export const resolvePath = (obj, pathOrFn) => {
14
+ if (typeof pathOrFn === 'function') {
15
+ return pathOrFn(obj);
16
+ }
17
+ if (typeof pathOrFn === 'string') {
18
+ const parts = pathOrFn.split('.');
19
+ let value = obj;
20
+ for (const part of parts) {
21
+ if (value == null) return undefined;
22
+ value = value[part];
23
+ }
24
+ return value;
25
+ }
26
+ return undefined;
27
+ };
28
+
29
+ /**
30
+ * Rule that requires the user to be authenticated
31
+ * Checks for user existence at the specified path in context
32
+ * @param {string|Function} [userPath='user'] - Path to user in context (e.g., 'user', 'auth.user', 'session.user')
33
+ * @returns {Function} Rule function (parent, args, ctx, info) => boolean | throws
34
+ * @example
35
+ * requireAuth() // checks ctx.user
36
+ * requireAuth('auth.user') // checks ctx.auth.user
37
+ * requireAuth('session.currentUser') // checks ctx.session.currentUser
38
+ */
39
+ export const requireAuth = (userPath = 'user') => {
40
+ return (_parent, _args, ctx) => {
41
+ if (!ctx) {
42
+ throw new UnauthenticatedError('You must be logged in to access this resource');
43
+ }
44
+ const user = resolvePath(ctx, userPath);
45
+ if (!user) {
46
+ throw new UnauthenticatedError('You must be logged in to access this resource');
47
+ }
48
+ return true;
49
+ };
50
+ };
51
+
52
+ /**
53
+ * Rule that requires the user to have a specific role
54
+ * @param {string|string[]} role - Required role or array of roles (any match)
55
+ * @param {Object} [options] - Configuration options
56
+ * @param {string|Function} [options.userPath='user'] - Path to user in context
57
+ * @param {string|Function} [options.rolePath='role'] - Path to role field in user object
58
+ * @returns {Function} Rule function (parent, args, ctx, info) => boolean | throws
59
+ * @example
60
+ * requireRole('ADMIN')
61
+ * requireRole(['ADMIN', 'EDITOR'])
62
+ * requireRole('ADMIN', { userPath: 'auth.user', rolePath: 'roles.primary' })
63
+ */
64
+ export const requireRole = (role, options = {}) => {
65
+ const roles = Array.isArray(role) ? role : [role];
66
+ if (roles.length === 0 || Array.from(roles).some(value => typeof value !== 'string' || value.length === 0)) {
67
+ throw new TypeError('Required roles must be a nonempty string or array of nonempty strings');
68
+ }
69
+ const { userPath = 'user', rolePath = 'role' } = options;
70
+
71
+ return (_parent, _args, ctx) => {
72
+ if (!ctx) {
73
+ throw new UnauthenticatedError('You must be logged in to access this resource');
74
+ }
75
+ const user = resolvePath(ctx, userPath);
76
+ if (!user) {
77
+ throw new UnauthenticatedError('You must be logged in to access this resource');
78
+ }
79
+
80
+ const userRole = resolvePath(user, rolePath);
81
+
82
+ if (!roles.includes(userRole)) {
83
+ throw new ForbiddenError(`Requires role: ${roles.join(' or ')}`);
84
+ }
85
+
86
+ return true;
87
+ };
88
+ };
89
+
90
+ /**
91
+ * Rule that requires the user to have a specific permission
92
+ * @param {string|string[]} permission - Required permission(s) (all must match if array)
93
+ * @param {Object} [options] - Configuration options
94
+ * @param {string|Function} [options.userPath='user'] - Path to user in context
95
+ * @param {string|Function} [options.permissionsPath='permissions'] - Path to permissions array in user object
96
+ * @returns {Function} Rule function (parent, args, ctx, info) => boolean | throws
97
+ * @example
98
+ * requirePermission('posts:read')
99
+ * requirePermission(['posts:read', 'posts:write'])
100
+ * requirePermission('posts:read', { userPath: 'auth.user', permissionsPath: 'grants' })
101
+ */
102
+ export const requirePermission = (permission, options = {}) => {
103
+ const requiredPermissions = Array.isArray(permission) ? permission : [permission];
104
+ if (requiredPermissions.length === 0
105
+ || Array.from(requiredPermissions).some(perm => typeof perm !== 'string' || perm.length === 0)) {
106
+ throw new TypeError('Required permissions must be a nonempty string or array of nonempty strings');
107
+ }
108
+ const { userPath = 'user', permissionsPath = 'permissions' } = options;
109
+
110
+ return (_parent, _args, ctx) => {
111
+ if (!ctx) {
112
+ throw new UnauthenticatedError('You must be logged in to access this resource');
113
+ }
114
+ const user = resolvePath(ctx, userPath);
115
+ if (!user) {
116
+ throw new UnauthenticatedError('You must be logged in to access this resource');
117
+ }
118
+
119
+ const userPermissions = resolvePath(user, permissionsPath);
120
+ if (!Array.isArray(userPermissions)
121
+ || Array.from(userPermissions).some(perm => typeof perm !== 'string' || perm.length === 0)) {
122
+ throw new ForbiddenError('User permissions must be an array of nonempty strings');
123
+ }
124
+
125
+ // Check if user has wildcard permission
126
+ if (userPermissions.includes('*')) {
127
+ return true;
128
+ }
129
+
130
+ // All required permissions must be present
131
+ for (const perm of requiredPermissions) {
132
+ if (!userPermissions.includes(perm)) {
133
+ throw new ForbiddenError(`Missing permission: ${perm}`);
134
+ }
135
+ }
136
+
137
+ return true;
138
+ };
139
+ };
140
+
141
+ /**
142
+ * Composes multiple rules - all must pass (logical AND)
143
+ * @param {...Function} rules - Rule functions to compose
144
+ * @returns {Function} Composed rule function
145
+ */
146
+ export const composeRules = (...rules) => {
147
+ if (rules.some(rule => typeof rule !== 'function')) {
148
+ throw new TypeError('composeRules requires rule functions');
149
+ }
150
+ return async (parent, args, ctx, info) => {
151
+ for (const rule of rules) {
152
+ const result = await rule(parent, args, ctx, info);
153
+ // Only true or void grants access, consistently with direct rules.
154
+ if (result !== true && result !== undefined) {
155
+ return false;
156
+ }
157
+ // If rule throws, it will propagate
158
+ }
159
+ return true;
160
+ };
161
+ };
162
+
163
+ /**
164
+ * Creates a rule that allows access if ANY of the provided rules pass (logical OR)
165
+ * @param {...Function} rules - Rule functions to check
166
+ * @returns {Function} Combined rule function
167
+ */
168
+ export const anyRule = (...rules) => {
169
+ if (rules.some(rule => typeof rule !== 'function')) {
170
+ throw new TypeError('anyRule requires rule functions');
171
+ }
172
+ return async (parent, args, ctx, info) => {
173
+ let lastError = null;
174
+
175
+ for (const rule of rules) {
176
+ try {
177
+ const result = await rule(parent, args, ctx, info);
178
+ if (result === true || result === undefined) {
179
+ return true; // At least one rule passed
180
+ }
181
+ } catch (error) {
182
+ lastError = error;
183
+ // Continue to next rule
184
+ }
185
+ }
186
+
187
+ // No rule passed - throw the last error or return false
188
+ if (lastError) {
189
+ throw lastError;
190
+ }
191
+ return false;
192
+ };
193
+ };
194
+
195
+ const normalizeOwnerId = (value) => {
196
+ if (typeof value === 'string' && value.length > 0) return value;
197
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
198
+ return normalizeObjectId(value);
199
+ };
200
+
201
+ /**
202
+ * Creates a rule that checks if the authenticated user owns the resource
203
+ * @param {string|Function} [ownerField='userId'] - Path to owner ID in parent, or function to extract it
204
+ * @param {string|Function} [userIdField='id'] - Path to user ID in user object, or function to extract it
205
+ * @param {Object} [options] - Configuration options
206
+ * @param {string|Function} [options.userPath='user'] - Path to user in context
207
+ * @returns {Function} Rule function
208
+ * @example
209
+ * isOwner() // compares parent.userId with ctx.user.id
210
+ * isOwner('authorId') // compares parent.authorId with ctx.user.id
211
+ * isOwner('author.id', 'profile.id') // compares parent.author.id with ctx.user.profile.id
212
+ * isOwner('authorId', 'id', { userPath: 'auth.user' }) // uses ctx.auth.user instead of ctx.user
213
+ */
214
+ export const isOwner = (ownerField = 'userId', userIdField = 'id', options = {}) => {
215
+ const { userPath = 'user' } = options;
216
+
217
+ return (parent, _args, ctx) => {
218
+ if (!ctx) {
219
+ throw new UnauthenticatedError('You must be logged in to access this resource');
220
+ }
221
+ const user = resolvePath(ctx, userPath);
222
+ if (!user) {
223
+ throw new UnauthenticatedError('You must be logged in to access this resource');
224
+ }
225
+
226
+ // Get ownerId from parent (using path or function)
227
+ const ownerId = normalizeOwnerId(resolvePath(parent, ownerField));
228
+
229
+ // Get userId from user object (using path or function)
230
+ const userId = normalizeOwnerId(resolvePath(user, userIdField));
231
+
232
+ if (ownerId === undefined || userId === undefined) {
233
+ return false;
234
+ }
235
+
236
+ return ownerId === userId;
237
+ };
238
+ };
239
+
240
+ /**
241
+ * Creates a custom rule from a predicate function
242
+ * @param {Function} predicate - Function returning true/void to allow, or throwing to deny
243
+ * @param {string} errorMessage - Error message if rule fails
244
+ * @param {string} errorCode - Error code (FORBIDDEN or UNAUTHENTICATED)
245
+ * @returns {Function} Rule function
246
+ */
247
+ export const createRule = (predicate, errorMessage = 'Access denied', errorCode = 'FORBIDDEN') => {
248
+ if (typeof predicate !== 'function') {
249
+ throw new TypeError('createRule requires a predicate function');
250
+ }
251
+ return async (parent, args, ctx, info) => {
252
+ const result = await predicate(parent, args, ctx, info);
253
+
254
+ if (result !== true && result !== undefined) {
255
+ if (errorCode === 'UNAUTHENTICATED') {
256
+ throw new UnauthenticatedError(errorMessage);
257
+ }
258
+ throw new ForbiddenError(errorMessage);
259
+ }
260
+
261
+ return true;
262
+ };
263
+ };
264
+
265
+ /**
266
+ * Rule that always allows access (useful for public fields)
267
+ * @returns {Function} Rule function that always returns true
268
+ */
269
+ export const allow = () => {
270
+ return () => true;
271
+ };
272
+
273
+ /**
274
+ * Rule that always denies access
275
+ * @param {string} message - Optional denial message
276
+ * @returns {Function} Rule function that always throws ForbiddenError
277
+ */
278
+ export const deny = (message = 'Access denied') => {
279
+ return () => {
280
+ throw new ForbiddenError(message);
281
+ };
282
+ };
283
+
284
+ // Export all rules as an object for convenience
285
+ const rules = {
286
+ // Utility
287
+ resolvePath,
288
+ // Rule helpers
289
+ requireAuth,
290
+ requireRole,
291
+ requirePermission,
292
+ composeRules,
293
+ anyRule,
294
+ isOwner,
295
+ createRule,
296
+ allow,
297
+ deny,
298
+ };
299
+
300
+ export default rules;
@@ -0,0 +1,19 @@
1
+ import { GraphQLEnumType } from 'graphql';
2
+
3
+ const QLOperator = new GraphQLEnumType({
4
+ name: 'QLOperator',
5
+ values: {
6
+ EQ: { value: 'EQ' },
7
+ LT: { value: 'LT' },
8
+ GT: { value: 'GT' },
9
+ LTE: { value: 'LTE' },
10
+ GTE: { value: 'GTE' },
11
+ BTW: { value: 'BTW' },
12
+ NE: { value: 'NE' },
13
+ IN: { value: 'IN' },
14
+ NIN: { value: 'NIN' },
15
+ LIKE: { value: 'LIKE' },
16
+ },
17
+ });
18
+
19
+ export default QLOperator;
@@ -0,0 +1,24 @@
1
+ import {
2
+ GraphQLInputObjectType,
3
+ GraphQLNonNull,
4
+ GraphQLEnumType,
5
+ GraphQLString,
6
+ } from 'graphql';
7
+
8
+ const QLSortOrder = new GraphQLEnumType({
9
+ name: 'QLSortOrder',
10
+ values: {
11
+ DESC: { value: 'DESC' },
12
+ ASC: { value: 'ASC' },
13
+ },
14
+ });
15
+
16
+ const QLSort = new GraphQLInputObjectType({
17
+ name: 'QLSort',
18
+ fields: () => ({
19
+ field: { type: new GraphQLNonNull(GraphQLString) },
20
+ order: { type: new GraphQLNonNull(QLSortOrder) },
21
+ }),
22
+ });
23
+
24
+ export default QLSort;
@@ -0,0 +1,32 @@
1
+ import { GraphQLScalarType, Kind } from 'graphql';
2
+ import SimfinityError from '../errors/simfinity.error.js';
3
+
4
+ const parseQLValue = (value) => value;
5
+
6
+ const parseLiteral = (ast) => {
7
+ switch (ast.kind) {
8
+ case Kind.INT: return parseInt(ast.value, 10);
9
+ case Kind.FLOAT: return parseFloat(ast.value);
10
+ case Kind.BOOLEAN: return ast.value === true || ast.value === 'true';
11
+ case Kind.STRING: return ast.value;
12
+ case Kind.NULL: return null;
13
+ case Kind.LIST:
14
+ return ast.values.map((item) => {
15
+ if (item.kind === Kind.LIST) {
16
+ throw new SimfinityError('Nested filter value lists are not supported', 'INVALID_FILTER_VALUE', 400);
17
+ }
18
+ return parseLiteral(item);
19
+ });
20
+ default:
21
+ throw new SimfinityError('Filter values must be scalars or flat scalar lists', 'INVALID_FILTER_VALUE', 400);
22
+ }
23
+ };
24
+
25
+ const QLValue = new GraphQLScalarType({
26
+ name: 'QLValue',
27
+ serialize: parseQLValue,
28
+ parseValue: parseQLValue,
29
+ parseLiteral,
30
+ });
31
+
32
+ export default QLValue;
@@ -0,0 +1,11 @@
1
+ import SimfinityError from './simfinity.error.js';
2
+
3
+ class InternalServerError extends SimfinityError {
4
+ constructor(message, cause) {
5
+ super(message, 'INTERNAL_SERVER_ERROR');
6
+ this.cause = cause;
7
+ this.getCause = () => this.cause;
8
+ }
9
+ }
10
+
11
+ export default InternalServerError;
@@ -0,0 +1,15 @@
1
+ class SimfinityError extends Error {
2
+ constructor(message, code, status) {
3
+ super(message);
4
+ this.extensions = {
5
+ code,
6
+ status,
7
+ timestamp: new Date().toUTCString(),
8
+ };
9
+ this.getCode = () => this.extensions.code;
10
+ this.getStatus = () => this.extensions.status;
11
+ this.getTimestamp = () => this.extensions.timestamp;
12
+ }
13
+ }
14
+
15
+ export default SimfinityError;
package/src/index.js ADDED
@@ -0,0 +1,17 @@
1
+ export { describeModels } from './metadata.js';
2
+ export { default as SimfinityError } from './errors/simfinity.error.js';
3
+ export { createValidatedScalar } from './scalars/factory.js';
4
+ export {
5
+ createRuntime,
6
+ buildErrorFormatter,
7
+ InternalServerError,
8
+ } from './runtime.js';
9
+ export { createQueryPlan, resolveModelPath } from './query-plan.js';
10
+ export { default as QLOperator } from './const/QLOperator.js';
11
+ export { default as QLSort } from './const/QLSort.js';
12
+ export { default as QLValue } from './const/QLValue.js';
13
+ export { configureQueryLimits, paginationStages } from './query-limits.js';
14
+ export { default as auth } from './auth/index.js';
15
+ export { default as plugins } from './plugins.js';
16
+ export { default as scalars } from './scalars.js';
17
+ export { default as validators } from './validators.js';
@@ -0,0 +1,37 @@
1
+ import {
2
+ GraphQLBoolean,
3
+ GraphQLObjectType,
4
+ GraphQLString,
5
+ __Field,
6
+ } from 'graphql';
7
+
8
+ // getFields() supports both lazy and already materialized introspection fields.
9
+ // Reuse the shared field on repeated module evaluation to preserve type identity.
10
+ const introspectionFields = __Field.getFields();
11
+ if (!introspectionFields.extensions) {
12
+ const RelationType = new GraphQLObjectType({
13
+ name: 'RelationType',
14
+ fields: () => ({
15
+ embedded: { type: GraphQLBoolean },
16
+ connectionField: { type: GraphQLString },
17
+ displayField: { type: GraphQLString },
18
+ }),
19
+ });
20
+
21
+ const FieldExtensionsType = new GraphQLObjectType({
22
+ name: 'FieldExtensionsType',
23
+ fields: () => ({
24
+ relation: { type: RelationType },
25
+ stateMachine: { type: GraphQLBoolean },
26
+ readOnly: { type: GraphQLBoolean },
27
+ }),
28
+ });
29
+
30
+ introspectionFields.extensions = {
31
+ type: FieldExtensionsType,
32
+ name: 'extensions',
33
+ resolve: (obj) => obj.extensions,
34
+ args: [],
35
+ isDeprecated: false,
36
+ };
37
+ }
@@ -0,0 +1,140 @@
1
+ import {
2
+ isObjectType, isScalarType, isEnumType, isNonNullType, isListType,
3
+ } from 'graphql';
4
+ import SimfinityError from './errors/simfinity.error.js';
5
+
6
+ const invalid = (message) => {
7
+ throw new SimfinityError(message, 'INVALID_MODEL', 400);
8
+ };
9
+
10
+ const unwrap = (type) => {
11
+ const required = isNonNullType(type);
12
+ const outer = required ? type.ofType : type;
13
+ const list = isListType(outer);
14
+ const item = list ? outer.ofType : outer;
15
+ const itemRequired = list && isNonNullType(item);
16
+ const named = isNonNullType(item) ? item.ofType : item;
17
+ if (isListType(named)) invalid('Nested scalar/object lists are not supported');
18
+ return { named, required, list, itemRequired };
19
+ };
20
+
21
+ const scalarDescription = (type) => {
22
+ if (isEnumType(type)) {
23
+ const storedValues = new Map();
24
+ for (const entry of type.getValues()) {
25
+ const stored = String(entry.value);
26
+ if (storedValues.has(stored) && !Object.is(storedValues.get(stored), entry.value)) invalid(`Enum storage collision in ${type.name}: distinct internal values serialize to ${stored}`);
27
+ storedValues.set(stored, entry.value);
28
+ }
29
+ return { scalar: 'Enum', values: [...storedValues.keys()], enumValues: type.getValues().map(({ name, value }) => ({ name, value })) };
30
+ }
31
+ let base = type;
32
+ const visited = new Set();
33
+ while (base.baseScalarType) {
34
+ if (visited.has(base)) invalid(`Cyclic base scalar ${type.name}`);
35
+ visited.add(base);
36
+ base = base.baseScalarType;
37
+ }
38
+ const scalar = ['DateTime', 'Date', 'Time'].includes(base.name) ? 'DateTime' : base.name;
39
+ if (!['String', 'ID', 'Int', 'Float', 'Boolean', 'DateTime'].includes(scalar)) {
40
+ invalid(`Unsupported scalar ${type.name}: declare a supported baseScalarType`);
41
+ }
42
+ return { scalar };
43
+ };
44
+
45
+ /** Describe persistent entities without importing a database driver or mutating GraphQL types. */
46
+ export const describeModels = (registrations) => {
47
+ if (!Array.isArray(registrations)) invalid('registrations must be an array');
48
+ const knownTypes = new Map();
49
+ const entities = new Map();
50
+ const queue = [];
51
+ const registerType = (type) => {
52
+ if (!isObjectType(type)) invalid('Each registration must contain a GraphQLObjectType in gqltype');
53
+ if (knownTypes.has(type.name) && knownTypes.get(type.name) !== type) invalid(`Conflicting GraphQL types named ${type.name}`);
54
+ knownTypes.set(type.name, type);
55
+ };
56
+ const addEntity = (type) => {
57
+ registerType(type);
58
+ if (!entities.has(type.name)) {
59
+ entities.set(type.name, { name: type.name, gqltype: type, fields: [], indexes: [] });
60
+ queue.push(type);
61
+ }
62
+ };
63
+ for (const registration of registrations) registerType(registration.gqltype);
64
+ for (const registration of registrations) if (registration.endpoint !== false) addEntity(registration.gqltype);
65
+
66
+ const fieldsOf = (type, ancestors, path, embedded = false) => {
67
+ if (ancestors.includes(type)) invalid(`Embedded cycle at ${path}`);
68
+ registerType(type);
69
+ const fields = Object.values(type.getFields()).map((field) => {
70
+ const { named, ...wrappers } = unwrap(field.type);
71
+ const extensions = field.extensions || {};
72
+ const descriptor = {
73
+ name: field.name, storageName: field.name, ...wrappers,
74
+ unique: extensions.unique === true, readOnly: extensions.readOnly === true,
75
+ };
76
+ if (isScalarType(named) || isEnumType(named)) {
77
+ return { ...descriptor, kind: 'scalar', ...scalarDescription(named) };
78
+ }
79
+ if (!isObjectType(named)) invalid(`Unsupported field type at ${path}.${field.name}`);
80
+ const relation = extensions.relation;
81
+ if (!relation) invalid(`${path}.${field.name} requires extensions.relation`);
82
+ if (relation.embedded) {
83
+ return {
84
+ ...descriptor, kind: 'embedded',
85
+ fields: fieldsOf(named, [...ancestors, type], `${path}.${field.name}`, true),
86
+ };
87
+ }
88
+ addEntity(named);
89
+ if (wrappers.list) {
90
+ if (!relation.connectionField) invalid(`${path}.${field.name} requires a child connectionField`);
91
+ if (embedded) invalid(`Non-embedded collections inside embedded objects are not supported: ${path}.${field.name}`);
92
+ return { ...descriptor, kind: 'collection', target: named.name, connectionField: relation.connectionField };
93
+ }
94
+ return { ...descriptor, kind: 'reference', target: named.name, storageName: relation.connectionField || field.name };
95
+ });
96
+ const storage = new Set();
97
+ for (const field of fields.filter((item) => item.kind !== 'collection')) {
98
+ if (storage.has(field.storageName)) invalid(`Storage collision at ${path}.${field.storageName}`);
99
+ storage.add(field.storageName);
100
+ }
101
+ return fields;
102
+ };
103
+
104
+ for (let position = 0; position < queue.length; position += 1) {
105
+ const type = queue[position];
106
+ const entity = entities.get(type.name);
107
+ entity.fields = fieldsOf(type, [], type.name);
108
+ entity.indexes = (type.extensions?.indexes || []).map((index) => {
109
+ if (!Array.isArray(index.fields) || !index.fields.length || new Set(index.fields).size !== index.fields.length) {
110
+ invalid(`Invalid index on ${type.name}: fields must be a nonempty list of distinct field names`);
111
+ }
112
+ return { fields: [...index.fields], unique: index.unique === true };
113
+ });
114
+ }
115
+ for (const entity of entities.values()) {
116
+ for (const field of entity.fields.filter((item) => item.kind === 'collection')) {
117
+ const child = entities.get(field.target);
118
+ if (child.fields.some((item) => item.name === field.connectionField && item.kind === 'collection')) {
119
+ invalid(`Implicit many-to-many relation at ${entity.name}.${field.name}: use an explicit link entity`);
120
+ }
121
+ const named = child.fields.find((item) => item.name === field.connectionField && item.kind !== 'collection');
122
+ const stored = child.fields.find((item) => item.storageName === field.connectionField && item.kind !== 'collection');
123
+ if (named && stored && named !== stored) invalid(`Conflicting connectionField ${child.name}.${field.connectionField}`);
124
+ const inverse = named || stored;
125
+ if (inverse) {
126
+ if (inverse.kind === 'scalar' && inverse.scalar === 'ID' && !inverse.list) {
127
+ Object.assign(inverse, { kind: 'reference', target: entity.name, inferred: true });
128
+ }
129
+ if (inverse.kind !== 'reference' || inverse.target !== entity.name) invalid(`Conflicting inverse relation at ${child.name}.${field.connectionField}`);
130
+ field.connectionField = inverse.storageName;
131
+ } else {
132
+ child.fields.push({
133
+ name: field.connectionField, storageName: field.connectionField, kind: 'reference', target: entity.name,
134
+ required: false, list: false, itemRequired: false, unique: false, readOnly: false, private: true,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ return { entities: [...entities.values()].sort((a, b) => a.name.localeCompare(b.name)) };
140
+ };
package/src/plugins.js ADDED
@@ -0,0 +1,53 @@
1
+ import { createAuthPlugin } from './auth/index.js';
2
+
3
+ export { createAuthPlugin } from './auth/index.js';
4
+
5
+ /**
6
+ * Shared Apollo Server plugin to add count to GraphQL response extensions
7
+ * @returns {Object} Apollo Server plugin
8
+ */
9
+ export const apolloCountPlugin = () => {
10
+ return {
11
+ async requestDidStart() {
12
+ return {
13
+ async willSendResponse({ contextValue, response }) {
14
+ if (response.body.kind === 'single' && contextValue?.count) {
15
+ response.body.singleResult.extensions = {
16
+ ...(response.body.singleResult.extensions || {}),
17
+ count: contextValue.count,
18
+ };
19
+ }
20
+ },
21
+ };
22
+ },
23
+ };
24
+ };
25
+
26
+ /**
27
+ * Envelop plugin to add count to GraphQL response extensions
28
+ * @returns {Object} Envelop plugin
29
+ */
30
+ export const envelopCountPlugin = () => {
31
+ return {
32
+ onExecute() {
33
+ return {
34
+ onExecuteDone({ result, args }) {
35
+ if (args.contextValue?.count) {
36
+ result.extensions = {
37
+ ...result.extensions,
38
+ count: args.contextValue.count,
39
+ };
40
+ }
41
+ },
42
+ };
43
+ },
44
+ };
45
+ };
46
+
47
+ const plugins = {
48
+ createAuthPlugin,
49
+ apolloCountPlugin,
50
+ envelopCountPlugin,
51
+ };
52
+
53
+ export default plugins;
@@ -0,0 +1,26 @@
1
+ import SimfinityError from './errors/simfinity.error.js';
2
+
3
+ let queryMaxPageSize = 1000;
4
+
5
+ export const configureQueryLimits = (options = {}) => {
6
+ if (!options || typeof options !== 'object' || Array.isArray(options)
7
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(options))) {
8
+ throw new SimfinityError('Query limit options must be a plain object', 'INVALID_QUERY_LIMITS', 400);
9
+ }
10
+ const { maxPageSize = 1000 } = options;
11
+ if (!Number.isSafeInteger(maxPageSize) || maxPageSize < 1) {
12
+ throw new SimfinityError('maxPageSize must be a positive safe integer', 'INVALID_QUERY_LIMITS', 400);
13
+ }
14
+ queryMaxPageSize = maxPageSize;
15
+ };
16
+
17
+ export const paginationStages = (pagination, withDefault) => {
18
+ if (pagination == null) return withDefault ? [{ $skip: 0 }, { $limit: Math.min(100, queryMaxPageSize) }] : [];
19
+ const { page, size } = pagination;
20
+ const skip = size * (page - 1);
21
+ if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(size) || size < 1
22
+ || size > queryMaxPageSize || !Number.isSafeInteger(skip)) {
23
+ throw new SimfinityError(`Pagination requires positive safe integers and size <= ${queryMaxPageSize}`, 'INVALID_PAGINATION', 400);
24
+ }
25
+ return [{ $skip: skip }, { $limit: size }];
26
+ };