@constructive-io/graphql-server 5.1.0 → 5.3.2

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 CHANGED
@@ -56,7 +56,7 @@ This starts the server with env defaults from `@constructive-io/graphql-env`.
56
56
 
57
57
  ## What it does
58
58
 
59
- Runs an Express server that wires CORS, uploads, domain parsing, auth, and PostGraphile into a single GraphQL endpoint. It serves `/graphql` and `/graphiql`, injects per-request `pgSettings`, and flushes cached schemas on demand or via database notifications. When meta API is enabled, it resolves API config (schemas, roles, modules) from the meta schema using the request host and enforces `api.isPublic`, with optional header overrides in private mode; when meta API is disabled, it serves the fixed schemas and roles from `api.exposedSchemas`, `api.anonRole`, `api.roleName`, and `api.defaultDatabaseId`.
59
+ Runs an Express server that wires CORS, uploads, domain parsing, auth, and PostGraphile into a single GraphQL endpoint. It serves `/graphql` and `/graphiql`, injects per-request `pgSettings`, and flushes cached schemas on demand or via database notifications. When meta API is enabled, it resolves API config (schemas, roles, modules) from the meta schema using the request host and enforces `api.isPublic`, with optional header overrides in private mode; when meta API is disabled, it serves the fixed schemas and roles from `api.exposedSchemas`, `api.anonRole`, and `api.roleName`.
60
60
 
61
61
  ## Key Features
62
62
 
@@ -99,21 +99,17 @@ For the operational workflow, sampler output, and heap snapshot usage, see [docs
99
99
  - `GET /debug/memory` -> memory/process/Graphile debug snapshot when observability is enabled
100
100
  - `GET /debug/db` -> PostgreSQL activity/locks/pool debug snapshot when observability is enabled
101
101
 
102
- ## Meta API routing
102
+ ## Scoped routing
103
103
 
104
- When `API_ENABLE_META=true` (default):
104
+ This is a production-only server: every request is resolved through the scoped-routing plane. There is no static single-tenant mode and no flag to disable routing. For single-database local development without route resolution or a database id, use [`@constructive-io/graphql-dev-server`](../dev-server/README.md).
105
105
 
106
- - The server resolves APIs from `services_public.domains` using the request host.
106
+ - The server resolves the request host with a single `resolve_route()` call against the compiled route bindings in the scoped routing schema (`API_SCOPED_ROUTING_SCHEMA`, default `constructive_routing_public`), mapping host → tenant/api/database/role.
107
107
  - Only APIs where `api.is_public` matches `API_IS_PUBLIC` are served.
108
108
  - In private mode (`API_IS_PUBLIC=false`), you can override with headers:
109
109
  - `X-Api-Name` + `X-Database-Id`
110
110
  - `X-Schemata` + `X-Database-Id`
111
111
  - `X-Meta-Schema` + `X-Database-Id`
112
-
113
- When `API_ENABLE_META=false`:
114
-
115
- - The server skips meta lookups and serves the fixed schemas in `API_EXPOSED_SCHEMAS`.
116
- - Roles and database IDs come from `API_ANON_ROLE`, `API_ROLE_NAME`, and `API_DEFAULT_DATABASE_ID`.
112
+ - A resolved database id is always required. There is no default database, so a request that resolves without a database id is rejected (`NO_DATABASE_ID` → HTTP 500).
117
113
 
118
114
  ## Configuration
119
115
 
@@ -130,13 +126,12 @@ Configuration is merged from defaults, config files, and env vars via `@construc
130
126
  | `FEATURES_SIMPLE_INFLECTION` | Enable simple inflection | `true` |
131
127
  | `FEATURES_OPPOSITE_BASE_NAMES` | Enable opposite base names | `true` |
132
128
  | `FEATURES_POSTGIS` | Enable PostGIS support | `true` |
133
- | `API_ENABLE_META` | Enable meta API routing | `true` |
129
+ | `API_SCOPED_ROUTING_SCHEMA` | Schema containing `resolve_route()` | `constructive_routing_public` |
134
130
  | `API_IS_PUBLIC` | Serve public APIs only | `true` |
135
- | `API_EXPOSED_SCHEMAS` | Schemas when meta routing is disabled | empty |
136
- | `API_META_SCHEMAS` | Meta schemas to query | `services_public,metaschema_public,metaschema_modules_public` |
131
+ | `API_EXPOSED_SCHEMAS` | Additional schemas to expose | empty |
132
+ | `API_META_SCHEMAS` | Meta schemas to query | `constructive_routing_public,metaschema_public,metaschema_modules_public` |
137
133
  | `API_ANON_ROLE` | Anonymous role name | `administrator` |
138
134
  | `API_ROLE_NAME` | Authenticated role name | `administrator` |
139
- | `API_DEFAULT_DATABASE_ID` | Default database ID | `hard-coded` |
140
135
  | `GRAPHQL_OBSERVABILITY_ENABLED` | Master switch for debug routes and sampler | `false` |
141
136
  | `GRAPHQL_DEBUG_SAMPLER_ENABLED` | Enables periodic NDJSON sampling when observability is on | `true` |
142
137
  | `GRAPHQL_DEBUG_SAMPLER_INTERVAL_MS` | Sampler interval in milliseconds | `10000` |
@@ -72,6 +72,18 @@ const resolveModuleSettings = async (registry, ctx) => {
72
72
  // Helpers
73
73
  // =============================================================================
74
74
  const isApiError = (result) => !!result && typeof result.errorHtml === 'string';
75
+ /**
76
+ * Every resolved API surface must carry a database id — there is no default
77
+ * database. A resolved structure without one is a misconfiguration; fail loud
78
+ * rather than silently proceeding with an undefined tenant.
79
+ */
80
+ const assertDatabaseId = (result) => {
81
+ if (!result.databaseId) {
82
+ const error = new Error('No database id resolved for this request. A database id is required; there is no default database.');
83
+ error.code = 'NO_DATABASE_ID';
84
+ throw error;
85
+ }
86
+ };
75
87
  const parseCommaSeparatedHeader = (value) => value.split(',').map((s) => s.trim()).filter(Boolean);
76
88
  const getPrivateHeaderMode = (headers) => {
77
89
  if (headers.apiName)
@@ -161,28 +173,11 @@ const queryByApiName = async (pool, databaseId, name, isPublic) => {
161
173
  // =============================================================================
162
174
  const determineMode = (ctx) => {
163
175
  const { opts, headers } = ctx;
164
- // Static single-tenant mode: scoped routing off — expose configured schemas
165
- // directly with no route resolution.
166
- if (!opts.api?.enableScopedRouting)
167
- return 'static';
168
176
  if (opts.api?.isPublic === false) {
169
177
  return getPrivateHeaderMode(headers) ?? 'scoped-route';
170
178
  }
171
179
  return 'scoped-route';
172
180
  };
173
- const resolveStatic = (ctx) => {
174
- const { opts } = ctx;
175
- return {
176
- dbname: opts.pg?.database ?? '',
177
- anonRole: opts.api?.anonRole ?? '',
178
- roleName: opts.api?.roleName ?? '',
179
- schema: opts.api?.exposedSchemas ?? [],
180
- apiModules: [],
181
- domains: [],
182
- databaseId: opts.api?.defaultDatabaseId,
183
- isPublic: false
184
- };
185
- };
186
181
  const resolveSchemataHeader = async (ctx, validatedSchemas) => {
187
182
  const { opts, headers } = ctx;
188
183
  const headerSchemas = parseCommaSeparatedHeader(headers.schemata);
@@ -221,8 +216,6 @@ const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
221
216
  */
222
217
  const resolveScopedRoute = async (ctx) => {
223
218
  const { opts, pool, host } = ctx;
224
- if (!opts.api?.enableScopedRouting)
225
- return null;
226
219
  const schema = opts.api?.scopedRoutingSchema || 'constructive_routing_public';
227
220
  const route = await resolveRoute(pool, schema, host);
228
221
  if (!route)
@@ -295,9 +288,6 @@ export const getApiConfig = async (opts, req) => {
295
288
  const mode = determineMode(ctx);
296
289
  let result;
297
290
  switch (mode) {
298
- case 'static':
299
- result = resolveStatic(ctx);
300
- break;
301
291
  case 'schemata-header':
302
292
  result = await resolveSchemataHeader(ctx, validatedSchemas);
303
293
  break;
@@ -313,6 +303,7 @@ export const getApiConfig = async (opts, req) => {
313
303
  }
314
304
  // Cache successful results
315
305
  if (result && !isApiError(result)) {
306
+ assertDatabaseId(result);
316
307
  svcCache.set(cacheKey, result);
317
308
  }
318
309
  return result;
@@ -323,22 +314,6 @@ export const getApiConfig = async (opts, req) => {
323
314
  export const createApiMiddleware = (opts) => {
324
315
  return async (req, res, next) => {
325
316
  log.debug(`[api-middleware] ${req.method} ${req.path}`);
326
- // Fast path: static single-tenant mode (scoped routing disabled) — no
327
- // route resolution, expose the configured schemas directly.
328
- if (!opts.api?.enableScopedRouting) {
329
- req.api = resolveStatic({
330
- opts,
331
- pool: null,
332
- domain: '',
333
- subdomain: null,
334
- cacheKey: 'static',
335
- headers: {},
336
- host: ''
337
- });
338
- req.databaseId = req.api.databaseId;
339
- req.svc_key = 'static';
340
- return next();
341
- }
342
317
  try {
343
318
  const apiConfig = await getApiConfig(opts, req);
344
319
  if (isApiError(apiConfig)) {
@@ -360,6 +335,11 @@ export const createApiMiddleware = (opts) => {
360
335
  res.status(404).send(errorPage404Message(err.message));
361
336
  return;
362
337
  }
338
+ if (err.code === 'NO_DATABASE_ID') {
339
+ log.error('[api-middleware] no database id resolved:', err.message);
340
+ res.status(500).send(errorPage50x);
341
+ return;
342
+ }
363
343
  if (err.message?.includes('does not exist')) {
364
344
  res.status(404).send(errorPage404Message("The resource you're looking for does not exist."));
365
345
  return;
package/esm/server.js CHANGED
@@ -85,12 +85,8 @@ class Server {
85
85
  serverHost: effectiveOpts.server?.host,
86
86
  serverPort: effectiveOpts.server?.port,
87
87
  apiIsPublic: apiOpts.isPublic,
88
- enableScopedRouting: apiOpts.enableScopedRouting,
89
88
  scopedRoutingSchema: apiOpts.scopedRoutingSchema,
90
89
  metaSchemas: apiOpts.metaSchemas?.join(',') || 'default',
91
- exposedSchemas: apiOpts.exposedSchemas?.join(',') || 'none',
92
- anonRole: apiOpts.anonRole,
93
- roleName: apiOpts.roleName,
94
90
  observabilityEnabled
95
91
  });
96
92
  if (observabilityRequested && !observabilityEnabled) {
package/middleware/api.js CHANGED
@@ -78,6 +78,18 @@ const resolveModuleSettings = async (registry, ctx) => {
78
78
  // Helpers
79
79
  // =============================================================================
80
80
  const isApiError = (result) => !!result && typeof result.errorHtml === 'string';
81
+ /**
82
+ * Every resolved API surface must carry a database id — there is no default
83
+ * database. A resolved structure without one is a misconfiguration; fail loud
84
+ * rather than silently proceeding with an undefined tenant.
85
+ */
86
+ const assertDatabaseId = (result) => {
87
+ if (!result.databaseId) {
88
+ const error = new Error('No database id resolved for this request. A database id is required; there is no default database.');
89
+ error.code = 'NO_DATABASE_ID';
90
+ throw error;
91
+ }
92
+ };
81
93
  const parseCommaSeparatedHeader = (value) => value.split(',').map((s) => s.trim()).filter(Boolean);
82
94
  const getPrivateHeaderMode = (headers) => {
83
95
  if (headers.apiName)
@@ -169,28 +181,11 @@ const queryByApiName = async (pool, databaseId, name, isPublic) => {
169
181
  // =============================================================================
170
182
  const determineMode = (ctx) => {
171
183
  const { opts, headers } = ctx;
172
- // Static single-tenant mode: scoped routing off — expose configured schemas
173
- // directly with no route resolution.
174
- if (!opts.api?.enableScopedRouting)
175
- return 'static';
176
184
  if (opts.api?.isPublic === false) {
177
185
  return getPrivateHeaderMode(headers) ?? 'scoped-route';
178
186
  }
179
187
  return 'scoped-route';
180
188
  };
181
- const resolveStatic = (ctx) => {
182
- const { opts } = ctx;
183
- return {
184
- dbname: opts.pg?.database ?? '',
185
- anonRole: opts.api?.anonRole ?? '',
186
- roleName: opts.api?.roleName ?? '',
187
- schema: opts.api?.exposedSchemas ?? [],
188
- apiModules: [],
189
- domains: [],
190
- databaseId: opts.api?.defaultDatabaseId,
191
- isPublic: false
192
- };
193
- };
194
189
  const resolveSchemataHeader = async (ctx, validatedSchemas) => {
195
190
  const { opts, headers } = ctx;
196
191
  const headerSchemas = parseCommaSeparatedHeader(headers.schemata);
@@ -229,8 +224,6 @@ const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
229
224
  */
230
225
  const resolveScopedRoute = async (ctx) => {
231
226
  const { opts, pool, host } = ctx;
232
- if (!opts.api?.enableScopedRouting)
233
- return null;
234
227
  const schema = opts.api?.scopedRoutingSchema || 'constructive_routing_public';
235
228
  const route = await (0, routing_1.resolveRoute)(pool, schema, host);
236
229
  if (!route)
@@ -303,9 +296,6 @@ const getApiConfig = async (opts, req) => {
303
296
  const mode = determineMode(ctx);
304
297
  let result;
305
298
  switch (mode) {
306
- case 'static':
307
- result = resolveStatic(ctx);
308
- break;
309
299
  case 'schemata-header':
310
300
  result = await resolveSchemataHeader(ctx, validatedSchemas);
311
301
  break;
@@ -321,6 +311,7 @@ const getApiConfig = async (opts, req) => {
321
311
  }
322
312
  // Cache successful results
323
313
  if (result && !isApiError(result)) {
314
+ assertDatabaseId(result);
324
315
  server_utils_1.svcCache.set(cacheKey, result);
325
316
  }
326
317
  return result;
@@ -332,22 +323,6 @@ exports.getApiConfig = getApiConfig;
332
323
  const createApiMiddleware = (opts) => {
333
324
  return async (req, res, next) => {
334
325
  log.debug(`[api-middleware] ${req.method} ${req.path}`);
335
- // Fast path: static single-tenant mode (scoped routing disabled) — no
336
- // route resolution, expose the configured schemas directly.
337
- if (!opts.api?.enableScopedRouting) {
338
- req.api = resolveStatic({
339
- opts,
340
- pool: null,
341
- domain: '',
342
- subdomain: null,
343
- cacheKey: 'static',
344
- headers: {},
345
- host: ''
346
- });
347
- req.databaseId = req.api.databaseId;
348
- req.svc_key = 'static';
349
- return next();
350
- }
351
326
  try {
352
327
  const apiConfig = await (0, exports.getApiConfig)(opts, req);
353
328
  if (isApiError(apiConfig)) {
@@ -369,6 +344,11 @@ const createApiMiddleware = (opts) => {
369
344
  res.status(404).send((0, _404_message_1.default)(err.message));
370
345
  return;
371
346
  }
347
+ if (err.code === 'NO_DATABASE_ID') {
348
+ log.error('[api-middleware] no database id resolved:', err.message);
349
+ res.status(500).send(_50x_1.default);
350
+ return;
351
+ }
372
352
  if (err.message?.includes('does not exist')) {
373
353
  res.status(404).send((0, _404_message_1.default)("The resource you're looking for does not exist."));
374
354
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "5.1.0",
3
+ "version": "5.3.2",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -41,39 +41,39 @@
41
41
  "backend"
42
42
  ],
43
43
  "dependencies": {
44
- "@constructive-io/csrf": "^0.17.0",
45
- "@constructive-io/express-context": "^0.13.0",
46
- "@constructive-io/graphql-env": "^3.17.0",
47
- "@constructive-io/graphql-types": "^3.16.0",
48
- "@constructive-io/query-builder": "^3.0.1",
49
- "@constructive-io/s3-utils": "^2.20.0",
50
- "@constructive-io/url-domains": "^2.19.0",
44
+ "@constructive-io/csrf": "^0.18.2",
45
+ "@constructive-io/express-context": "^0.14.2",
46
+ "@constructive-io/graphql-env": "^3.19.2",
47
+ "@constructive-io/graphql-types": "^3.18.2",
48
+ "@constructive-io/query-builder": "^3.1.2",
49
+ "@constructive-io/s3-utils": "^2.21.2",
50
+ "@constructive-io/url-domains": "^2.20.2",
51
51
  "@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
52
- "@pgpmjs/env": "^2.29.0",
53
- "@pgpmjs/logger": "^2.14.0",
54
- "@pgpmjs/server-utils": "^3.15.4",
55
- "@pgpmjs/types": "^2.36.0",
56
- "agentic-server": "0.11.3",
52
+ "@pgpmjs/env": "^2.30.2",
53
+ "@pgpmjs/logger": "^2.15.2",
54
+ "@pgpmjs/server-utils": "^3.16.2",
55
+ "@pgpmjs/types": "^2.37.2",
56
+ "agentic-server": "0.12.2",
57
57
  "cors": "^2.8.6",
58
58
  "deepmerge": "^4.3.1",
59
59
  "express": "^5.2.1",
60
- "gql-ast": "^3.13.0",
60
+ "gql-ast": "^3.14.2",
61
61
  "grafast": "1.0.2",
62
62
  "grafserv": "1.0.0",
63
63
  "graphile-build": "5.0.2",
64
64
  "graphile-build-pg": "5.0.2",
65
- "graphile-cache": "^4.0.1",
65
+ "graphile-cache": "^4.1.2",
66
66
  "graphile-config": "1.0.1",
67
- "graphile-function-bindings": "^1.0.5",
68
- "graphile-settings": "^6.0.5",
67
+ "graphile-function-bindings": "^1.1.2",
68
+ "graphile-settings": "^6.1.2",
69
69
  "graphile-utils": "5.0.1",
70
70
  "graphql": "16.13.0",
71
71
  "graphql-upload": "^13.0.0",
72
72
  "lru-cache": "^11.2.7",
73
73
  "pg": "^8.21.0",
74
- "pg-cache": "^3.15.3",
75
- "pg-env": "^1.18.1",
76
- "pg-query-context": "^2.19.0",
74
+ "pg-cache": "^3.16.2",
75
+ "pg-env": "^1.19.2",
76
+ "pg-query-context": "^2.20.2",
77
77
  "pg-sql2": "5.0.1",
78
78
  "postgraphile": "5.0.3",
79
79
  "request-ip": "^3.3.0"
@@ -87,10 +87,10 @@
87
87
  "@types/pg": "^8.20.0",
88
88
  "@types/request-ip": "^0.0.41",
89
89
  "cookie-parser": "^1.4.7",
90
- "graphile-test": "5.0.5",
90
+ "graphile-test": "5.1.2",
91
91
  "makage": "^0.3.0",
92
92
  "nodemon": "^3.1.14",
93
93
  "ts-node": "^10.9.2"
94
94
  },
95
- "gitHead": "39db6d576bce7bc1de8123849f237b1695779d88"
95
+ "gitHead": "08741ce3a6897192c2dbc7d1da48b9f456ffb59f"
96
96
  }
package/server.js CHANGED
@@ -92,12 +92,8 @@ class Server {
92
92
  serverHost: effectiveOpts.server?.host,
93
93
  serverPort: effectiveOpts.server?.port,
94
94
  apiIsPublic: apiOpts.isPublic,
95
- enableScopedRouting: apiOpts.enableScopedRouting,
96
95
  scopedRoutingSchema: apiOpts.scopedRoutingSchema,
97
96
  metaSchemas: apiOpts.metaSchemas?.join(',') || 'default',
98
- exposedSchemas: apiOpts.exposedSchemas?.join(',') || 'none',
99
- anonRole: apiOpts.anonRole,
100
- roleName: apiOpts.roleName,
101
97
  observabilityEnabled
102
98
  });
103
99
  if (observabilityRequested && !observabilityEnabled) {