@constructive-io/graphql-server 3.1.1 → 4.0.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.
Files changed (77) hide show
  1. package/errors/404-message.js +1 -1
  2. package/errors/api-errors.d.ts +200 -0
  3. package/errors/api-errors.js +276 -0
  4. package/esm/errors/404-message.js +1 -1
  5. package/esm/errors/api-errors.js +261 -0
  6. package/esm/index.js +2 -0
  7. package/esm/middleware/api.js +355 -277
  8. package/esm/middleware/auth.js +25 -7
  9. package/esm/middleware/error-handler.js +86 -0
  10. package/esm/middleware/favicon.js +12 -0
  11. package/esm/middleware/graphile.js +149 -64
  12. package/esm/options.js +232 -0
  13. package/esm/schema.js +24 -11
  14. package/esm/server.js +41 -5
  15. package/index.d.ts +1 -0
  16. package/index.js +2 -0
  17. package/middleware/api.d.ts +3 -15
  18. package/middleware/api.js +359 -283
  19. package/middleware/auth.js +25 -7
  20. package/middleware/error-handler.d.ts +4 -0
  21. package/middleware/error-handler.js +94 -0
  22. package/middleware/favicon.d.ts +2 -0
  23. package/middleware/favicon.js +16 -0
  24. package/middleware/graphile.d.ts +14 -0
  25. package/middleware/graphile.js +149 -64
  26. package/options.d.ts +131 -0
  27. package/options.js +244 -0
  28. package/package.json +23 -24
  29. package/schema.d.ts +2 -2
  30. package/schema.js +23 -10
  31. package/server.d.ts +24 -2
  32. package/server.js +39 -3
  33. package/codegen/orm/client.d.ts +0 -55
  34. package/codegen/orm/client.js +0 -75
  35. package/codegen/orm/index.d.ts +0 -36
  36. package/codegen/orm/index.js +0 -59
  37. package/codegen/orm/input-types.d.ts +0 -20140
  38. package/codegen/orm/input-types.js +0 -2
  39. package/codegen/orm/models/api.d.ts +0 -42
  40. package/codegen/orm/models/api.js +0 -76
  41. package/codegen/orm/models/domain.d.ts +0 -42
  42. package/codegen/orm/models/domain.js +0 -76
  43. package/codegen/orm/models/index.d.ts +0 -7
  44. package/codegen/orm/models/index.js +0 -12
  45. package/codegen/orm/mutation/index.d.ts +0 -7
  46. package/codegen/orm/mutation/index.js +0 -7
  47. package/codegen/orm/query/index.d.ts +0 -20
  48. package/codegen/orm/query/index.js +0 -24
  49. package/codegen/orm/query-builder.d.ts +0 -81
  50. package/codegen/orm/query-builder.js +0 -496
  51. package/codegen/orm/select-types.d.ts +0 -83
  52. package/codegen/orm/select-types.js +0 -7
  53. package/codegen/orm/types.d.ts +0 -6
  54. package/codegen/orm/types.js +0 -23
  55. package/esm/codegen/orm/client.js +0 -70
  56. package/esm/codegen/orm/index.js +0 -39
  57. package/esm/codegen/orm/input-types.js +0 -1
  58. package/esm/codegen/orm/models/api.js +0 -72
  59. package/esm/codegen/orm/models/domain.js +0 -72
  60. package/esm/codegen/orm/models/index.js +0 -7
  61. package/esm/codegen/orm/mutation/index.js +0 -4
  62. package/esm/codegen/orm/query/index.js +0 -21
  63. package/esm/codegen/orm/query-builder.js +0 -452
  64. package/esm/codegen/orm/select-types.js +0 -6
  65. package/esm/codegen/orm/types.js +0 -7
  66. package/esm/middleware/gql.js +0 -116
  67. package/esm/plugins/PublicKeySignature.js +0 -114
  68. package/esm/scripts/codegen-schema.js +0 -71
  69. package/esm/scripts/create-bucket.js +0 -40
  70. package/middleware/gql.d.ts +0 -164
  71. package/middleware/gql.js +0 -121
  72. package/plugins/PublicKeySignature.d.ts +0 -11
  73. package/plugins/PublicKeySignature.js +0 -121
  74. package/scripts/codegen-schema.d.ts +0 -1
  75. package/scripts/codegen-schema.js +0 -76
  76. package/scripts/create-bucket.d.ts +0 -1
  77. package/scripts/create-bucket.js +0 -42
@@ -8,6 +8,7 @@ const isDev = () => getNodeEnv() === 'development';
8
8
  export const createAuthenticateMiddleware = (opts) => {
9
9
  return async (req, res, next) => {
10
10
  const api = req.api;
11
+ log.info(`[auth] middleware called, api=${api ? 'present' : 'missing'}`);
11
12
  if (!api) {
12
13
  res.status(500).send('Missing API info');
13
14
  return;
@@ -17,19 +18,26 @@ export const createAuthenticateMiddleware = (opts) => {
17
18
  database: api.dbname,
18
19
  });
19
20
  const rlsModule = api.rlsModule;
21
+ log.info(`[auth] rlsModule=${rlsModule ? 'present' : 'missing'}, ` +
22
+ `authenticate=${rlsModule?.authenticate ?? 'none'}, ` +
23
+ `authenticateStrict=${rlsModule?.authenticateStrict ?? 'none'}, ` +
24
+ `privateSchema=${rlsModule?.privateSchema?.schemaName ?? 'none'}`);
20
25
  if (!rlsModule) {
21
- if (isDev())
22
- log.debug('No RLS module configured, skipping auth');
26
+ log.info('[auth] No RLS module configured, skipping auth');
23
27
  return next();
24
28
  }
25
- const authFn = opts.server.strictAuth
29
+ const authFn = opts.server?.strictAuth
26
30
  ? rlsModule.authenticateStrict
27
31
  : rlsModule.authenticate;
32
+ log.info(`[auth] strictAuth=${opts.server?.strictAuth ?? false}, authFn=${authFn ?? 'none'}`);
28
33
  if (authFn && rlsModule.privateSchema.schemaName) {
29
34
  const { authorization = '' } = req.headers;
30
35
  const [authType, authToken] = authorization.split(' ');
31
36
  let token = {};
37
+ log.info(`[auth] authorization header present=${!!authorization}, ` +
38
+ `authType=${authType ?? 'none'}, hasToken=${!!authToken}`);
32
39
  if (authType?.toLowerCase() === 'bearer' && authToken) {
40
+ log.info('[auth] Processing bearer token authentication');
33
41
  const context = {
34
42
  'jwt.claims.ip_address': req.clientIp,
35
43
  };
@@ -39,25 +47,28 @@ export const createAuthenticateMiddleware = (opts) => {
39
47
  if (req.get('User-Agent')) {
40
48
  context['jwt.claims.user_agent'] = req.get('User-Agent');
41
49
  }
50
+ const authQuery = `SELECT * FROM "${rlsModule.privateSchema.schemaName}"."${authFn}"($1)`;
51
+ log.info(`[auth] Executing auth query: ${authQuery}`);
42
52
  try {
43
53
  const result = await pgQueryContext({
44
54
  client: pool,
45
55
  context,
46
- query: `SELECT * FROM "${rlsModule.privateSchema.schemaName}"."${authFn}"($1)`,
56
+ query: authQuery,
47
57
  variables: [authToken],
48
58
  });
59
+ log.info(`[auth] Query result: rowCount=${result?.rowCount}`);
49
60
  if (result?.rowCount === 0) {
61
+ log.info('[auth] No rows returned, returning UNAUTHENTICATED');
50
62
  res.status(200).json({
51
63
  errors: [{ extensions: { code: 'UNAUTHENTICATED' } }],
52
64
  });
53
65
  return;
54
66
  }
55
67
  token = result.rows[0];
56
- if (isDev())
57
- log.debug(`Auth success: role=${token.role}`);
68
+ log.info(`[auth] Auth success: role=${token.role}, user_id=${token.user_id}`);
58
69
  }
59
70
  catch (e) {
60
- log.error('Auth error:', e.message);
71
+ log.error('[auth] Auth error:', e.message);
61
72
  res.status(200).json({
62
73
  errors: [
63
74
  {
@@ -71,8 +82,15 @@ export const createAuthenticateMiddleware = (opts) => {
71
82
  return;
72
83
  }
73
84
  }
85
+ else {
86
+ log.info('[auth] No bearer token provided, using anonymous auth');
87
+ }
74
88
  req.token = token;
75
89
  }
90
+ else {
91
+ log.info(`[auth] Skipping auth: authFn=${authFn ?? 'none'}, ` +
92
+ `privateSchema=${rlsModule.privateSchema?.schemaName ?? 'none'}`);
93
+ }
76
94
  next();
77
95
  };
78
96
  };
@@ -0,0 +1,86 @@
1
+ import { getNodeEnv } from '@constructive-io/graphql-env';
2
+ import { Logger } from '@pgpmjs/logger';
3
+ import { isApiError } from '../errors/api-errors';
4
+ import errorPage404Message from '../errors/404-message';
5
+ import errorPage50x from '../errors/50x';
6
+ import './types';
7
+ const log = new Logger('error-handler');
8
+ const isDevelopment = () => getNodeEnv() === 'development';
9
+ const wantsJson = (req) => {
10
+ const accept = req.get('Accept') || '';
11
+ return accept.includes('application/json') || accept.includes('application/graphql-response+json');
12
+ };
13
+ const sanitizeMessage = (error) => {
14
+ if (isDevelopment())
15
+ return error.message;
16
+ if (isApiError(error))
17
+ return error.message;
18
+ if (error.message?.includes('ECONNREFUSED'))
19
+ return 'Service temporarily unavailable';
20
+ if (error.message?.includes('timeout') || error.message?.includes('ETIMEDOUT'))
21
+ return 'Request timed out';
22
+ if (error.message?.includes('does not exist'))
23
+ return 'The requested resource does not exist';
24
+ return 'An unexpected error occurred';
25
+ };
26
+ const categorizeError = (err) => {
27
+ if (isApiError(err)) {
28
+ return {
29
+ statusCode: err.statusCode,
30
+ code: err.code,
31
+ message: sanitizeMessage(err),
32
+ logLevel: err.statusCode >= 500 ? 'error' : 'warn',
33
+ };
34
+ }
35
+ if (err.message?.includes('ECONNREFUSED') || err.message?.includes('connection terminated')) {
36
+ return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: sanitizeMessage(err), logLevel: 'error' };
37
+ }
38
+ if (err.message?.includes('timeout') || err.message?.includes('ETIMEDOUT')) {
39
+ return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message: sanitizeMessage(err), logLevel: 'error' };
40
+ }
41
+ return { statusCode: 500, code: 'INTERNAL_ERROR', message: sanitizeMessage(err), logLevel: 'error' };
42
+ };
43
+ const sendResponse = (req, res, { statusCode, code, message }) => {
44
+ if (wantsJson(req)) {
45
+ res.status(statusCode).json({ error: { code, message, requestId: req.requestId } });
46
+ }
47
+ else {
48
+ res.status(statusCode).send(statusCode >= 500 ? errorPage50x : errorPage404Message(message));
49
+ }
50
+ };
51
+ const logError = (err, req, level) => {
52
+ const context = {
53
+ requestId: req.requestId,
54
+ path: req.path,
55
+ method: req.method,
56
+ host: req.get('host'),
57
+ databaseId: req.databaseId,
58
+ svcKey: req.svc_key,
59
+ clientIp: req.clientIp,
60
+ };
61
+ if (isApiError(err)) {
62
+ log[level]({ event: 'api_error', code: err.code, statusCode: err.statusCode, message: err.message, ...context });
63
+ }
64
+ else {
65
+ log[level]({ event: 'unexpected_error', name: err.name, message: err.message, stack: isDevelopment() ? err.stack : undefined, ...context });
66
+ }
67
+ };
68
+ export const errorHandler = (err, req, res, _next) => {
69
+ if (res.headersSent) {
70
+ log.warn({ event: 'headers_already_sent', requestId: req.requestId, path: req.path, errorMessage: err.message });
71
+ return;
72
+ }
73
+ const response = categorizeError(err);
74
+ logError(err, req, response.logLevel);
75
+ sendResponse(req, res, response);
76
+ };
77
+ export const notFoundHandler = (req, res, _next) => {
78
+ const message = `Route not found: ${req.method} ${req.path}`;
79
+ log.warn({ event: 'route_not_found', path: req.path, method: req.method, requestId: req.requestId });
80
+ if (wantsJson(req)) {
81
+ res.status(404).json({ error: { code: 'NOT_FOUND', message, requestId: req.requestId } });
82
+ }
83
+ else {
84
+ res.status(404).send(errorPage404Message(message));
85
+ }
86
+ };
@@ -0,0 +1,12 @@
1
+ const FAVICON_BASE64 = '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';
2
+ const faviconBuffer = Buffer.from(FAVICON_BASE64, 'base64');
3
+ export const favicon = (req, res, next) => {
4
+ if (req.path === '/favicon.ico' && req.method === 'GET') {
5
+ res.setHeader('Content-Type', 'image/x-icon');
6
+ res.setHeader('Content-Length', faviconBuffer.length);
7
+ res.setHeader('Cache-Control', 'public, max-age=86400');
8
+ res.status(200).send(faviconBuffer);
9
+ return;
10
+ }
11
+ next();
12
+ };
@@ -1,12 +1,116 @@
1
1
  import { Logger } from '@pgpmjs/logger';
2
- import { graphileCache } from 'graphile-cache';
3
- import { getGraphileSettings as getSettings } from 'graphile-settings';
4
- import { getPgPool } from 'pg-cache';
5
- import { postgraphile } from 'postgraphile';
2
+ import { createGraphileInstance, graphileCache } from 'graphile-cache';
3
+ import { ConstructivePreset, makePgService } from 'graphile-settings';
4
+ import { buildConnectionString } from 'pg-cache';
5
+ import { getPgEnvOptions } from 'pg-env';
6
6
  import './types'; // for Request type
7
- import PublicKeySignature from '../plugins/PublicKeySignature';
7
+ import { HandlerCreationError } from '../errors/api-errors';
8
+ /**
9
+ * Custom maskError function that always returns the original error.
10
+ *
11
+ * By default, grafserv masks errors for security (hiding sensitive database errors
12
+ * from clients). We disable this masking to show full error messages.
13
+ *
14
+ * Upstream reference:
15
+ * - grafserv defaultMaskError: node_modules/grafserv/dist/options.js
16
+ * - SafeError interface: grafast isSafeError() - errors implementing SafeError
17
+ * are shown as-is even with default masking
18
+ *
19
+ * If you need to restore masking behavior, see the upstream implementation which:
20
+ * 1. Returns GraphQLError instances as-is
21
+ * 2. Returns SafeError instances with their message exposed
22
+ * 3. Masks other errors with a hash/ID and logs the original
23
+ */
24
+ const maskError = (error) => {
25
+ return error;
26
+ };
27
+ // =============================================================================
28
+ // Single-Flight Pattern: In-Flight Tracking
29
+ // =============================================================================
30
+ /**
31
+ * Tracks in-flight handler creation promises to prevent duplicate creations.
32
+ * When multiple concurrent requests arrive for the same cache key, only the
33
+ * first request creates the handler while others wait on the same promise.
34
+ */
35
+ const creating = new Map();
36
+ /**
37
+ * Returns the number of currently in-flight handler creation operations.
38
+ * Useful for monitoring and debugging.
39
+ */
40
+ export function getInFlightCount() {
41
+ return creating.size;
42
+ }
43
+ /**
44
+ * Returns the cache keys for all currently in-flight handler creation operations.
45
+ * Useful for monitoring and debugging.
46
+ */
47
+ export function getInFlightKeys() {
48
+ return [...creating.keys()];
49
+ }
50
+ /**
51
+ * Clears the in-flight map. Used for testing purposes.
52
+ */
53
+ export function clearInFlightMap() {
54
+ creating.clear();
55
+ }
8
56
  const log = new Logger('graphile');
9
57
  const reqLabel = (req) => req.requestId ? `[${req.requestId}]` : '[req]';
58
+ /**
59
+ * Build a PostGraphile v5 preset for a tenant
60
+ */
61
+ const buildPreset = (connectionString, schemas, anonRole, roleName) => ({
62
+ extends: [ConstructivePreset],
63
+ pgServices: [
64
+ makePgService({
65
+ connectionString,
66
+ schemas,
67
+ }),
68
+ ],
69
+ grafserv: {
70
+ graphqlPath: '/graphql',
71
+ graphiqlPath: '/graphiql',
72
+ graphiql: true,
73
+ maskError,
74
+ },
75
+ grafast: {
76
+ explain: process.env.NODE_ENV === 'development',
77
+ context: (requestContext) => {
78
+ // In grafserv/express/v4, the request is available at requestContext.expressv4.req
79
+ const req = requestContext?.expressv4?.req;
80
+ const context = {};
81
+ if (req) {
82
+ if (req.databaseId) {
83
+ context['jwt.claims.database_id'] = req.databaseId;
84
+ }
85
+ if (req.clientIp) {
86
+ context['jwt.claims.ip_address'] = req.clientIp;
87
+ }
88
+ if (req.get('origin')) {
89
+ context['jwt.claims.origin'] = req.get('origin');
90
+ }
91
+ if (req.get('User-Agent')) {
92
+ context['jwt.claims.user_agent'] = req.get('User-Agent');
93
+ }
94
+ if (req.token?.user_id) {
95
+ return {
96
+ pgSettings: {
97
+ role: roleName,
98
+ 'jwt.claims.token_id': req.token.id,
99
+ 'jwt.claims.user_id': req.token.user_id,
100
+ ...context,
101
+ },
102
+ };
103
+ }
104
+ }
105
+ return {
106
+ pgSettings: {
107
+ role: anonRole,
108
+ ...context,
109
+ },
110
+ };
111
+ },
112
+ },
113
+ });
10
114
  export const graphile = (opts) => {
11
115
  return async (req, res, next) => {
12
116
  const label = reqLabel(req);
@@ -23,76 +127,57 @@ export const graphile = (opts) => {
23
127
  }
24
128
  const { dbname, anonRole, roleName, schema } = api;
25
129
  const schemaLabel = schema?.join(',') || 'unknown';
130
+ // =========================================================================
131
+ // Phase A: Cache Check (fast path)
132
+ // =========================================================================
26
133
  const cached = graphileCache.get(key);
27
134
  if (cached) {
28
135
  log.debug(`${label} PostGraphile cache hit key=${key} db=${dbname} schemas=${schemaLabel}`);
29
136
  return cached.handler(req, res, next);
30
137
  }
31
138
  log.debug(`${label} PostGraphile cache miss key=${key} db=${dbname} schemas=${schemaLabel}`);
32
- const options = getSettings({
33
- ...opts,
34
- graphile: {
35
- ...opts.graphile,
36
- schema: schema,
37
- },
38
- });
39
- const pubkey_challenge = api.apiModules.find((mod) => mod.name === 'pubkey_challenge');
40
- if (pubkey_challenge && pubkey_challenge.data) {
41
- log.info(`${label} Enabling PublicKeySignature plugin for ${dbname}`);
42
- options.appendPlugins.push(PublicKeySignature(pubkey_challenge.data));
43
- }
44
- options.appendPlugins = options.appendPlugins ?? [];
45
- if (opts.graphile?.appendPlugins) {
46
- options.appendPlugins.push(...opts.graphile.appendPlugins);
47
- }
48
- options.pgSettings = async function pgSettings(request) {
49
- const gqlReq = request;
50
- const settingsLabel = reqLabel(gqlReq);
51
- const context = {
52
- [`jwt.claims.database_id`]: gqlReq.databaseId,
53
- [`jwt.claims.ip_address`]: gqlReq.clientIp,
54
- };
55
- if (gqlReq.get('origin')) {
56
- context['jwt.claims.origin'] = gqlReq.get('origin');
139
+ // =========================================================================
140
+ // Phase B: In-Flight Check (single-flight coalescing)
141
+ // =========================================================================
142
+ const inFlight = creating.get(key);
143
+ if (inFlight) {
144
+ log.debug(`${label} Coalescing request for PostGraphile[${key}] - waiting for in-flight creation`);
145
+ try {
146
+ const instance = await inFlight;
147
+ return instance.handler(req, res, next);
57
148
  }
58
- if (gqlReq.get('User-Agent')) {
59
- context['jwt.claims.user_agent'] = gqlReq.get('User-Agent');
149
+ catch (error) {
150
+ // Re-throw to be caught by outer try-catch
151
+ throw error;
60
152
  }
61
- if (gqlReq?.token?.user_id) {
62
- log.debug(`${settingsLabel} pgSettings role=${roleName} db=${gqlReq.databaseId} ip=${gqlReq.clientIp}`);
63
- return {
64
- role: roleName,
65
- [`jwt.claims.token_id`]: gqlReq.token.id,
66
- [`jwt.claims.user_id`]: gqlReq.token.user_id,
67
- ...context,
68
- };
69
- }
70
- log.debug(`${settingsLabel} pgSettings role=${anonRole} db=${gqlReq.databaseId} ip=${gqlReq.clientIp}`);
71
- return { role: anonRole, ...context };
72
- };
73
- options.graphqlRoute = '/graphql';
74
- options.graphiqlRoute = '/graphiql';
75
- options.graphileBuildOptions = {
76
- ...options.graphileBuildOptions,
77
- ...opts.graphile?.graphileBuildOptions,
78
- };
79
- const graphileOpts = {
80
- ...options,
81
- ...opts.graphile?.overrideSettings,
82
- };
83
- log.info(`${label} Building PostGraphile handler key=${key} db=${dbname} schemas=${schemaLabel} role=${roleName} anon=${anonRole}`);
84
- const pgPool = getPgPool({
153
+ }
154
+ // =========================================================================
155
+ // Phase C: Create New Handler (first request for this key)
156
+ // =========================================================================
157
+ log.info(`${label} Building PostGraphile v5 handler key=${key} db=${dbname} schemas=${schemaLabel} role=${roleName} anon=${anonRole}`);
158
+ const pgConfig = getPgEnvOptions({
85
159
  ...opts.pg,
86
160
  database: dbname,
87
161
  });
88
- const handler = postgraphile(pgPool, schema, graphileOpts);
89
- graphileCache.set(key, {
90
- pgPool,
91
- pgPoolKey: dbname,
92
- handler,
93
- });
94
- log.info(`${label} Cached PostGraphile handler key=${key} db=${dbname}`);
95
- return handler(req, res, next);
162
+ const connectionString = buildConnectionString(pgConfig.user, pgConfig.password, pgConfig.host, pgConfig.port, pgConfig.database);
163
+ // Create promise and store in in-flight map BEFORE try block
164
+ const preset = buildPreset(connectionString, schema || [], anonRole, roleName);
165
+ const creationPromise = createGraphileInstance({ preset, cacheKey: key });
166
+ creating.set(key, creationPromise);
167
+ try {
168
+ const instance = await creationPromise;
169
+ graphileCache.set(key, instance);
170
+ log.info(`${label} Cached PostGraphile v5 handler key=${key} db=${dbname}`);
171
+ return instance.handler(req, res, next);
172
+ }
173
+ catch (error) {
174
+ log.error(`${label} Failed to create PostGraphile[${key}]:`, error);
175
+ throw new HandlerCreationError(`Failed to create handler for ${key}: ${error instanceof Error ? error.message : String(error)}`, { cacheKey: key, cause: error instanceof Error ? error.message : String(error) });
176
+ }
177
+ finally {
178
+ // Always clean up in-flight tracker
179
+ creating.delete(key);
180
+ }
96
181
  }
97
182
  catch (e) {
98
183
  log.error(`${label} PostGraphile middleware error`, e);
package/esm/options.js ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * GraphQL Server Options - Configuration utilities
3
+ *
4
+ * This module provides type-safe configuration utilities for the GraphQL server.
5
+ * It includes type guards for runtime validation and utility functions for
6
+ * configuration normalization.
7
+ *
8
+ * The main configuration type is `ConstructiveOptions` from @constructive-io/graphql-types.
9
+ *
10
+ * @module options
11
+ */
12
+ import deepmerge from 'deepmerge';
13
+ import { graphileDefaults, graphileFeatureDefaults, apiDefaults } from '@constructive-io/graphql-types';
14
+ // ============================================
15
+ // Default Configuration
16
+ // ============================================
17
+ /**
18
+ * Default configuration values for GraphQL server
19
+ *
20
+ * Provides sensible defaults for all currently active fields.
21
+ */
22
+ export const serverDefaults = {
23
+ pg: {
24
+ host: 'localhost',
25
+ port: 5432,
26
+ user: 'postgres',
27
+ password: 'password',
28
+ database: 'postgres'
29
+ },
30
+ server: {
31
+ host: 'localhost',
32
+ port: 3000,
33
+ trustProxy: false,
34
+ strictAuth: false
35
+ },
36
+ api: apiDefaults,
37
+ graphile: graphileDefaults,
38
+ features: graphileFeatureDefaults
39
+ };
40
+ // ============================================
41
+ // Type Guards
42
+ // ============================================
43
+ /**
44
+ * List of all recognized fields in ConstructiveOptions
45
+ */
46
+ const RECOGNIZED_FIELDS = [
47
+ 'pg',
48
+ 'server',
49
+ 'api',
50
+ 'graphile',
51
+ 'features',
52
+ 'db',
53
+ 'cdn',
54
+ 'deployment',
55
+ 'migrations',
56
+ 'jobs'
57
+ ];
58
+ /**
59
+ * Type guard to validate if an unknown value is a valid ConstructiveOptions object
60
+ *
61
+ * Validates that:
62
+ * 1. The value is a non-null object
63
+ * 2. Contains at least one recognized field from the interface
64
+ * 3. All recognized fields that exist have object values (not primitives)
65
+ *
66
+ * @param opts - Unknown value to validate
67
+ * @returns True if opts is a valid ConstructiveOptions object
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * if (isConstructiveOptions(unknownConfig)) {
72
+ * // TypeScript knows unknownConfig is ConstructiveOptions
73
+ * const { pg, server } = unknownConfig;
74
+ * }
75
+ * ```
76
+ */
77
+ export function isConstructiveOptions(opts) {
78
+ if (opts === null || opts === undefined) {
79
+ return false;
80
+ }
81
+ if (typeof opts !== 'object') {
82
+ return false;
83
+ }
84
+ const obj = opts;
85
+ // Check for at least one recognized field from the interface
86
+ const hasRecognizedField = RECOGNIZED_FIELDS.some((field) => field in obj);
87
+ if (!hasRecognizedField) {
88
+ return false;
89
+ }
90
+ // Validate that recognized fields have object values (not primitives)
91
+ for (const field of RECOGNIZED_FIELDS) {
92
+ if (field in obj && obj[field] !== undefined && obj[field] !== null) {
93
+ if (typeof obj[field] !== 'object') {
94
+ return false;
95
+ }
96
+ }
97
+ }
98
+ return true;
99
+ }
100
+ /**
101
+ * Type guard to check if an object has PostgreSQL configuration
102
+ *
103
+ * @param opts - Unknown value to check
104
+ * @returns True if opts has a defined pg property
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * if (hasPgConfig(config)) {
109
+ * console.log(config.pg.host);
110
+ * }
111
+ * ```
112
+ */
113
+ export function hasPgConfig(opts) {
114
+ if (opts === null || opts === undefined || typeof opts !== 'object') {
115
+ return false;
116
+ }
117
+ return 'pg' in opts && opts.pg !== undefined;
118
+ }
119
+ /**
120
+ * Type guard to check if an object has HTTP server configuration
121
+ *
122
+ * @param opts - Unknown value to check
123
+ * @returns True if opts has a defined server property
124
+ *
125
+ * @example
126
+ * ```typescript
127
+ * if (hasServerConfig(config)) {
128
+ * console.log(config.server.port);
129
+ * }
130
+ * ```
131
+ */
132
+ export function hasServerConfig(opts) {
133
+ if (opts === null || opts === undefined || typeof opts !== 'object') {
134
+ return false;
135
+ }
136
+ return 'server' in opts && opts.server !== undefined;
137
+ }
138
+ /**
139
+ * Type guard to check if an object has API configuration
140
+ *
141
+ * @param opts - Unknown value to check
142
+ * @returns True if opts has a defined api property
143
+ *
144
+ * @example
145
+ * ```typescript
146
+ * if (hasApiConfig(config)) {
147
+ * console.log(config.api.exposedSchemas);
148
+ * }
149
+ * ```
150
+ */
151
+ export function hasApiConfig(opts) {
152
+ if (opts === null || opts === undefined || typeof opts !== 'object') {
153
+ return false;
154
+ }
155
+ return 'api' in opts && opts.api !== undefined;
156
+ }
157
+ // ============================================
158
+ // Internal Utilities
159
+ // ============================================
160
+ /**
161
+ * Array merge strategy that replaces arrays (source wins over target).
162
+ * This ensures that when a user specifies an array value, it replaces
163
+ * the default rather than merging/concatenating.
164
+ *
165
+ * @internal
166
+ */
167
+ const replaceArrays = (_target, source) => source;
168
+ // ============================================
169
+ // Utility Functions
170
+ // ============================================
171
+ /**
172
+ * Legacy field names that indicate old configuration format
173
+ */
174
+ const LEGACY_FIELDS = [
175
+ 'schemas', // Old array-style schema config (should be graphile.schema)
176
+ 'pgConfig', // Old naming (should be pg)
177
+ 'serverPort', // Flat config (should be server.port)
178
+ 'serverHost', // Flat config (should be server.host)
179
+ 'dbConfig', // Old naming (should be db)
180
+ 'postgraphile', // Old Graphile v4 naming (should be graphile)
181
+ 'pgPool', // Direct pool config (deprecated)
182
+ 'jwtSecret', // Flat JWT config (should be in api or auth)
183
+ 'watchPg' // Old PostGraphile v4 option
184
+ ];
185
+ /**
186
+ * Detects if the given options object uses a deprecated/legacy format
187
+ *
188
+ * Checks for presence of legacy field names that indicate the configuration
189
+ * needs to be migrated to ConstructiveOptions format.
190
+ *
191
+ * @param opts - Unknown value to check
192
+ * @returns True if legacy configuration patterns are detected
193
+ *
194
+ * @example
195
+ * ```typescript
196
+ * if (isLegacyOptions(config)) {
197
+ * console.warn('Detected legacy configuration format. Please migrate to ConstructiveOptions.');
198
+ * }
199
+ * ```
200
+ */
201
+ export function isLegacyOptions(opts) {
202
+ if (opts === null || opts === undefined || typeof opts !== 'object') {
203
+ return false;
204
+ }
205
+ const obj = opts;
206
+ return LEGACY_FIELDS.some((field) => field in obj);
207
+ }
208
+ /**
209
+ * Normalizes input to a ConstructiveOptions object with defaults applied
210
+ *
211
+ * Accepts ConstructiveOptions and returns a fully normalized object
212
+ * with default values applied via deep merge. User-provided values override defaults.
213
+ *
214
+ * @param opts - ConstructiveOptions to normalize
215
+ * @returns ConstructiveOptions with defaults filled in
216
+ *
217
+ * @example
218
+ * ```typescript
219
+ * // Partial config - missing fields filled from defaults
220
+ * const normalized = normalizeServerOptions({
221
+ * pg: { database: 'myapp' }
222
+ * });
223
+ *
224
+ * // normalized.pg.host === 'localhost' (from default)
225
+ * // normalized.pg.database === 'myapp' (from user config)
226
+ * // normalized.server.port === 3000 (from default)
227
+ * ```
228
+ */
229
+ export function normalizeServerOptions(opts) {
230
+ // Deep merge with defaults - user options override defaults
231
+ return deepmerge(serverDefaults, opts, { arrayMerge: replaceArrays });
232
+ }