@constructive-io/express-context 0.30.0 → 0.31.1

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
@@ -57,7 +57,31 @@ app.post('/v1/chat', async (req, res) => {
57
57
 
58
58
  ## Module Loaders
59
59
 
60
- Each loader encapsulates a SQL query + type transform + per-databaseId LRU cache for one piece of per-database configuration. Loaders are registered in a `LoaderRegistry` and resolved lazily via `useModule(name)`.
60
+ Each loader encapsulates a SQL query + type transform + bounded LRU cache for
61
+ one piece of per-database configuration. The existing cache key is retained:
62
+ `databaseId:apiId` when an API ID is present, otherwise `databaseId`. TTLs are
63
+ hard expiry bounds, and concurrent misses for the same key share a single
64
+ resolution. Loaders are registered in a `LoaderRegistry` and resolved lazily via
65
+ `useModule(name)`.
66
+
67
+ Logical database IDs already distinguish tenant databases, and the optional API
68
+ ID distinguishes their API configuration. Pool identities and routing schemas
69
+ are not added to the key; extending cache identity is not required for these
70
+ TTL, concurrency, and invalidation improvements.
71
+
72
+ The existing `invalidate(databaseId?)` API is retained by design. Passing a
73
+ database ID invalidates that database and all of its API entries; omitting it
74
+ clears the loader's entire cache. Registry invalidation applies the same operation
75
+ to every registered loader. Matching in-flight resolutions
76
+ cannot repopulate an invalidated cache, although their existing callers can still
77
+ receive the results. Global and database-level invalidation are sufficient;
78
+ additional invalidation parameters are not required for this change.
79
+
80
+ Absence remains uncached by design: neither `undefined` results nor PostgreSQL
81
+ `42P01` (undefined table) results are stored, so subsequent calls can discover
82
+ newly available configuration. Concurrent calls may still share the same
83
+ in-flight resolution. Negative caching is not required for this change; other
84
+ resolution errors continue to propagate without being cached.
61
85
 
62
86
  ### Built-in loaders
63
87
 
package/esm/index.js CHANGED
@@ -45,6 +45,6 @@ export { BoundedCounter, CounterFlusher } from './usage-counter';
45
45
  // Request ID middleware
46
46
  export { requestIdMiddleware } from './request-id';
47
47
  export { buildContext, createContextMiddleware } from './context';
48
- export { agentChatLoader, authSettingsLoader, authSurfaceLoader, billingLoader, computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, eventsLoader, identityProvidersLoader, inferenceLogLoader, llmLoader, pubkeyLoader, requestProtectionLoader, requireDatabaseId, requireIdentityProvider, rlsLoader, webauthnLoader, } from './loaders';
48
+ export { agentChatLoader, authSettingsLoader, authSurfaceLoader, billingLoader, computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, DATABASE_STANDING_SQL, DATABASE_STANDING_TTL_MS, databaseSettingsLoader, eventsLoader, identityProvidersLoader, inferenceLogLoader, llmLoader, pubkeyLoader, requestProtectionLoader, requireDatabaseId, requireIdentityProvider, rlsLoader, standingLoader, webauthnLoader, } from './loaders';
49
49
  // Side-effect: Express type augmentation
50
50
  import './types';
@@ -2,8 +2,8 @@
2
2
  * create-loader — Factory for building cached ModuleLoader instances.
3
3
  *
4
4
  * Wraps a raw resolve function with an LRU cache keyed by databaseId:apiId.
5
- * Each loader gets its own independent cache with configurable TTL and
6
- * max entries.
5
+ * Each loader gets its own independent cache with a configurable hard TTL and
6
+ * maximum size.
7
7
  */
8
8
  import { Logger } from '@pgpmjs/logger';
9
9
  import { LRUCache } from 'lru-cache';
@@ -14,53 +14,82 @@ export function createModuleLoader(opts) {
14
14
  const cache = new LRUCache({
15
15
  max: opts.max ?? DEFAULT_MAX,
16
16
  ttl: opts.ttlMs ?? DEFAULT_TTL_MS,
17
- updateAgeOnGet: true,
17
+ updateAgeOnGet: false,
18
18
  allowStale: false,
19
19
  });
20
+ const pending = new Map();
20
21
  return {
21
22
  name: opts.name,
22
23
  async resolve(ctx) {
23
24
  const key = ctx.apiId ? `${ctx.databaseId}:${ctx.apiId}` : ctx.databaseId;
24
- if (cache.has(key)) {
25
+ const cached = cache.get(key);
26
+ if (cached !== undefined) {
25
27
  log.debug(`Cache HIT databaseId=${key}`);
26
- return cache.get(key);
28
+ return cached;
29
+ }
30
+ const existing = pending.get(key);
31
+ if (existing && !existing.invalidated) {
32
+ log.debug(`Cache COALESCE databaseId=${key}`);
33
+ return existing.promise;
27
34
  }
28
35
  log.debug(`Cache MISS databaseId=${key}, resolving`);
29
36
  // "Not provisioned" is expressed by the loader returning undefined, or
30
37
  // by the module's tables not existing at all (42P01 undefined_table).
31
38
  // Any other resolution error (bad query, ambiguous config) propagates —
32
39
  // never silently coerced into "module absent".
33
- try {
34
- const value = await opts.resolve(ctx);
35
- cache.set(key, value);
36
- return value;
37
- }
38
- catch (e) {
39
- if (e.code === '42P01') {
40
- log.debug(`Module tables absent for databaseId=${key}: ${e.message}`);
41
- cache.set(key, undefined);
42
- return undefined;
40
+ const resolution = {
41
+ databaseId: ctx.databaseId,
42
+ invalidated: false,
43
+ promise: Promise.resolve(undefined)
44
+ };
45
+ resolution.promise = Promise.resolve().then(async () => {
46
+ try {
47
+ const value = await opts.resolve(ctx);
48
+ // Keep absence uncached so subsequent calls can discover new config.
49
+ if (!resolution.invalidated && value !== undefined) {
50
+ cache.set(key, value);
51
+ }
52
+ return value;
43
53
  }
44
- log.warn(`Failed to resolve databaseId=${key}: ${e.message}`);
45
- throw e;
46
- }
54
+ catch (e) {
55
+ if (e.code === '42P01') {
56
+ log.debug(`Module tables absent for databaseId=${key}: ${e.message}`);
57
+ return undefined;
58
+ }
59
+ log.warn(`Failed to resolve databaseId=${key}: ${e.message}`);
60
+ throw e;
61
+ }
62
+ finally {
63
+ if (pending.get(key) === resolution) {
64
+ pending.delete(key);
65
+ }
66
+ }
67
+ });
68
+ pending.set(key, resolution);
69
+ return resolution.promise;
47
70
  },
48
71
  invalidate(databaseId) {
49
- if (databaseId) {
50
- // Clear the plain databaseId key and any composite databaseId:apiId keys
51
- let cleared = 0;
52
- for (const k of cache.keys()) {
53
- if (k === databaseId || k.startsWith(`${databaseId}:`)) {
54
- cache.delete(k);
72
+ if (!databaseId) {
73
+ const previousSize = cache.size;
74
+ cache.clear();
75
+ for (const resolution of pending.values()) {
76
+ resolution.invalidated = true;
77
+ }
78
+ log.debug(`Invalidated all entries (was size=${previousSize})`);
79
+ return;
80
+ }
81
+ let cleared = 0;
82
+ for (const key of cache.keys()) {
83
+ if (key === databaseId || key.startsWith(`${databaseId}:`)) {
84
+ if (cache.delete(key))
55
85
  cleared++;
56
- }
57
86
  }
58
- log.debug(`Invalidated ${cleared} entries for databaseId=${databaseId}`);
59
87
  }
60
- else {
61
- cache.clear();
62
- log.debug(`Invalidated all entries (was size=${cache.size})`);
88
+ for (const resolution of pending.values()) {
89
+ if (resolution.databaseId === databaseId)
90
+ resolution.invalidated = true;
63
91
  }
92
+ log.debug(`Invalidated ${cleared} entries for databaseId=${databaseId}`);
64
93
  },
65
94
  get cacheSize() {
66
95
  return cache.size;
@@ -10,6 +10,7 @@
10
10
  * - corsOrigins (routing-plane cors_settings)
11
11
  * - databaseSettings(routing-plane database_settings)
12
12
  * - requestProtection (routing-plane database_settings/api_settings bounds)
13
+ * - standing (metaschema_public.database suspended_at/suspended_reason, 5s TTL)
13
14
  * - pubkeyChallengeSettings (routing-plane pubkey_settings)
14
15
  * - webauthnSettings(routing-plane webauthn_settings)
15
16
  * - authSettings (metaschema_modules_public.sessions_module → tenant DB)
@@ -49,6 +50,7 @@ export { llmLoader } from './llm';
49
50
  export { pubkeyLoader } from './pubkey';
50
51
  export { requestProtectionLoader } from './request-protection';
51
52
  export { rlsLoader } from './rls';
53
+ export { DATABASE_STANDING_SQL, DATABASE_STANDING_TTL_MS, standingLoader } from './standing';
52
54
  export { webauthnLoader } from './webauthn';
53
55
  /**
54
56
  * Convenience: create a registry pre-loaded with all built-in loaders.
@@ -67,6 +69,7 @@ import { pubkeyLoader } from './pubkey';
67
69
  import { createLoaderRegistry } from './registry';
68
70
  import { requestProtectionLoader } from './request-protection';
69
71
  import { rlsLoader } from './rls';
72
+ import { standingLoader } from './standing';
70
73
  import { webauthnLoader } from './webauthn';
71
74
  export function createDefaultRegistry() {
72
75
  const registry = createLoaderRegistry();
@@ -84,5 +87,6 @@ export function createDefaultRegistry() {
84
87
  registry.register(computeLoader);
85
88
  registry.register(eventsLoader);
86
89
  registry.register(requestProtectionLoader);
90
+ registry.register(standingLoader);
87
91
  return registry;
88
92
  }
@@ -9,8 +9,8 @@
9
9
  * parallel. Useful for pre-warming or migration from the monolithic
10
10
  * svcCache pattern.
11
11
  *
12
- * Each loader's result is independently cached per databaseId — resolving
13
- * one module never invalidates another.
12
+ * Each loader's result is independently cached per databaseId and optional
13
+ * apiId — resolving one module never invalidates another.
14
14
  */
15
15
  import { Logger } from '@pgpmjs/logger';
16
16
  const log = new Logger('loader-registry');
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Database Standing Loader
3
+ *
4
+ * Whether a database may be served right now. The state is the two
5
+ * system-controlled columns on `metaschema_public.database`
6
+ * (`suspended_at`, `suspended_reason`) that billing and platform admins write;
7
+ * every lane reads it through this loader so the policy behind "suspended" can
8
+ * change without touching a serving path.
9
+ *
10
+ * The TTL is deliberately short: it bounds how long an established client
11
+ * keeps being served after its database is suspended.
12
+ *
13
+ * A plane without `metaschema_public.database` resolves to `undefined` like any
14
+ * other absent module (the factory's undefined_table handling); any other read
15
+ * failure propagates so the caller fails closed. A database the plane does not
16
+ * know is *not* servable — a request pinned to it has nothing to run against.
17
+ */
18
+ import { createModuleLoader } from './create-loader';
19
+ // ─── SQL ────────────────────────────────────────────────────────────────────
20
+ export const DATABASE_STANDING_SQL = `
21
+ SELECT suspended_at, suspended_reason
22
+ FROM metaschema_public.database
23
+ WHERE id = $1
24
+ `;
25
+ export const DATABASE_STANDING_TTL_MS = 5_000;
26
+ // ─── Loader ─────────────────────────────────────────────────────────────────
27
+ export const standingLoader = createModuleLoader({
28
+ name: 'standing',
29
+ ttlMs: DATABASE_STANDING_TTL_MS,
30
+ async resolve(ctx) {
31
+ const { routingPool, databaseId } = ctx;
32
+ const { rows } = await routingPool.query(DATABASE_STANDING_SQL, [databaseId]);
33
+ const row = rows[0];
34
+ if (!row)
35
+ return { exists: false, suspended: false, suspendedAt: null, reason: null };
36
+ const suspendedAt = row.suspended_at === null ? null : new Date(row.suspended_at);
37
+ return {
38
+ exists: true,
39
+ suspended: suspendedAt !== null,
40
+ suspendedAt,
41
+ reason: row.suspended_reason
42
+ };
43
+ }
44
+ });
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Module Loader Types
3
3
  *
4
- * A ModuleLoader is a per-database cached lookup that resolves config
5
- * from the routing DB or tenant DB. Each loader owns its own LRU cache
6
- * keyed by databaseId, with independent TTL and eviction.
4
+ * A ModuleLoader is a cached lookup that resolves config from the routing DB
5
+ * or tenant DB. Each loader owns an independent, bounded LRU keyed by
6
+ * databaseId and optional apiId.
7
7
  *
8
8
  * Loaders are registered in a LoaderRegistry and resolved in parallel
9
9
  * during context building. The result is a typed modules map on
package/index.d.ts CHANGED
@@ -34,7 +34,7 @@
34
34
  * });
35
35
  * ```
36
36
  */
37
- export type { AgentChatConfig, ApiConfigResult, ApiError, ApiStructure, AuthSettings, AuthSurface, BillingConfig, BuiltinModuleMap, ComputeConfig, ComputeModuleConfig, ConstructiveAPIToken, ConstructiveContext, DatabaseSettings, EventsConfig, IdentityProviderConfig, IdentityProvidersModule, InferenceLogConfig, LlmConfig, PubkeyChallengeSettings, RlsModule, WebauthnSettings, WithPgClient, } from './types';
37
+ export type { AgentChatConfig, ApiConfigResult, ApiError, ApiStructure, AuthSettings, AuthSurface, BillingConfig, BuiltinModuleMap, ComputeConfig, ComputeModuleConfig, ConstructiveAPIToken, ConstructiveContext, DatabaseSettings, DatabaseStanding, EventsConfig, IdentityProviderConfig, IdentityProvidersModule, InferenceLogConfig, LlmConfig, PubkeyChallengeSettings, RlsModule, WebauthnSettings, WithPgClient, } from './types';
38
38
  export type { BillingClient, InferenceLogEntry } from './billing-client';
39
39
  export { createBillingClient } from './billing-client';
40
40
  export type { PgSettingsInput } from './pg-settings';
@@ -52,5 +52,5 @@ export { requestIdMiddleware } from './request-id';
52
52
  export type { ContextMiddlewareOptions } from './context';
53
53
  export { buildContext, createContextMiddleware } from './context';
54
54
  export type { CreateLoaderOptions, LoaderContext, LoaderRegistry, ModuleLoader, } from './loaders';
55
- export { agentChatLoader, authSettingsLoader, authSurfaceLoader, billingLoader, computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, eventsLoader, identityProvidersLoader, inferenceLogLoader, llmLoader, pubkeyLoader, requestProtectionLoader, requireDatabaseId, requireIdentityProvider, rlsLoader, webauthnLoader, } from './loaders';
55
+ export { agentChatLoader, authSettingsLoader, authSurfaceLoader, billingLoader, computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, DATABASE_STANDING_SQL, DATABASE_STANDING_TTL_MS, databaseSettingsLoader, eventsLoader, identityProvidersLoader, inferenceLogLoader, llmLoader, pubkeyLoader, requestProtectionLoader, requireDatabaseId, requireIdentityProvider, rlsLoader, standingLoader, webauthnLoader, } from './loaders';
56
56
  import './types';
package/index.js CHANGED
@@ -36,8 +36,8 @@
36
36
  * ```
37
37
  */
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.rlsLoader = exports.requireIdentityProvider = exports.requireDatabaseId = exports.requestProtectionLoader = exports.pubkeyLoader = exports.llmLoader = exports.inferenceLogLoader = exports.identityProvidersLoader = exports.eventsLoader = exports.databaseSettingsLoader = exports.createModuleLoader = exports.createLoaderRegistry = exports.createDefaultRegistry = exports.corsLoader = exports.computeLoader = exports.billingLoader = exports.authSurfaceLoader = exports.authSettingsLoader = exports.agentChatLoader = exports.createContextMiddleware = exports.buildContext = exports.requestIdMiddleware = exports.CounterFlusher = exports.BoundedCounter = exports.UNKNOWN_SOURCE = exports.sourceBucket = exports.refusalRows = exports.RefusalRecorder = exports.refusalOverflowKey = exports.refusalKeyOf = exports.REFUSAL_REASONS = exports.OVERFLOW_SOURCE = exports.OVERFLOW_ROUTE = exports.DEFAULT_REFUSAL_MAX_KEYS = exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS = exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS = exports.createRecordRefusalsSink = exports.trustedProxyHops = exports.RateWindow = exports.ConcurrencyLimiter = exports.clientIpFrom = exports.resolveRequestProtection = exports.protectionPgSettings = exports.PROTECTION_BOUNDS = exports.INTROSPECTION_BOUND = exports.DEFAULT_REQUEST_PROTECTION = exports.ASSUMED_PAGE_SIZE = exports.withPgClient = exports.buildPgSettings = exports.createBillingClient = void 0;
40
- exports.webauthnLoader = void 0;
39
+ exports.requireDatabaseId = exports.requestProtectionLoader = exports.pubkeyLoader = exports.llmLoader = exports.inferenceLogLoader = exports.identityProvidersLoader = exports.eventsLoader = exports.databaseSettingsLoader = exports.DATABASE_STANDING_TTL_MS = exports.DATABASE_STANDING_SQL = exports.createModuleLoader = exports.createLoaderRegistry = exports.createDefaultRegistry = exports.corsLoader = exports.computeLoader = exports.billingLoader = exports.authSurfaceLoader = exports.authSettingsLoader = exports.agentChatLoader = exports.createContextMiddleware = exports.buildContext = exports.requestIdMiddleware = exports.CounterFlusher = exports.BoundedCounter = exports.UNKNOWN_SOURCE = exports.sourceBucket = exports.refusalRows = exports.RefusalRecorder = exports.refusalOverflowKey = exports.refusalKeyOf = exports.REFUSAL_REASONS = exports.OVERFLOW_SOURCE = exports.OVERFLOW_ROUTE = exports.DEFAULT_REFUSAL_MAX_KEYS = exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS = exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS = exports.createRecordRefusalsSink = exports.trustedProxyHops = exports.RateWindow = exports.ConcurrencyLimiter = exports.clientIpFrom = exports.resolveRequestProtection = exports.protectionPgSettings = exports.PROTECTION_BOUNDS = exports.INTROSPECTION_BOUND = exports.DEFAULT_REQUEST_PROTECTION = exports.ASSUMED_PAGE_SIZE = exports.withPgClient = exports.buildPgSettings = exports.createBillingClient = void 0;
40
+ exports.webauthnLoader = exports.standingLoader = exports.rlsLoader = exports.requireIdentityProvider = void 0;
41
41
  var billing_client_1 = require("./billing-client");
42
42
  Object.defineProperty(exports, "createBillingClient", { enumerable: true, get: function () { return billing_client_1.createBillingClient; } });
43
43
  var pg_settings_1 = require("./pg-settings");
@@ -90,6 +90,8 @@ Object.defineProperty(exports, "corsLoader", { enumerable: true, get: function (
90
90
  Object.defineProperty(exports, "createDefaultRegistry", { enumerable: true, get: function () { return loaders_1.createDefaultRegistry; } });
91
91
  Object.defineProperty(exports, "createLoaderRegistry", { enumerable: true, get: function () { return loaders_1.createLoaderRegistry; } });
92
92
  Object.defineProperty(exports, "createModuleLoader", { enumerable: true, get: function () { return loaders_1.createModuleLoader; } });
93
+ Object.defineProperty(exports, "DATABASE_STANDING_SQL", { enumerable: true, get: function () { return loaders_1.DATABASE_STANDING_SQL; } });
94
+ Object.defineProperty(exports, "DATABASE_STANDING_TTL_MS", { enumerable: true, get: function () { return loaders_1.DATABASE_STANDING_TTL_MS; } });
93
95
  Object.defineProperty(exports, "databaseSettingsLoader", { enumerable: true, get: function () { return loaders_1.databaseSettingsLoader; } });
94
96
  Object.defineProperty(exports, "eventsLoader", { enumerable: true, get: function () { return loaders_1.eventsLoader; } });
95
97
  Object.defineProperty(exports, "identityProvidersLoader", { enumerable: true, get: function () { return loaders_1.identityProvidersLoader; } });
@@ -100,6 +102,7 @@ Object.defineProperty(exports, "requestProtectionLoader", { enumerable: true, ge
100
102
  Object.defineProperty(exports, "requireDatabaseId", { enumerable: true, get: function () { return loaders_1.requireDatabaseId; } });
101
103
  Object.defineProperty(exports, "requireIdentityProvider", { enumerable: true, get: function () { return loaders_1.requireIdentityProvider; } });
102
104
  Object.defineProperty(exports, "rlsLoader", { enumerable: true, get: function () { return loaders_1.rlsLoader; } });
105
+ Object.defineProperty(exports, "standingLoader", { enumerable: true, get: function () { return loaders_1.standingLoader; } });
103
106
  Object.defineProperty(exports, "webauthnLoader", { enumerable: true, get: function () { return loaders_1.webauthnLoader; } });
104
107
  // Side-effect: Express type augmentation
105
108
  require("./types");
@@ -2,8 +2,8 @@
2
2
  * create-loader — Factory for building cached ModuleLoader instances.
3
3
  *
4
4
  * Wraps a raw resolve function with an LRU cache keyed by databaseId:apiId.
5
- * Each loader gets its own independent cache with configurable TTL and
6
- * max entries.
5
+ * Each loader gets its own independent cache with a configurable hard TTL and
6
+ * maximum size.
7
7
  */
8
8
  import type { LoaderContext, ModuleLoader } from './types';
9
9
  export interface CreateLoaderOptions<T> {
@@ -3,8 +3,8 @@
3
3
  * create-loader — Factory for building cached ModuleLoader instances.
4
4
  *
5
5
  * Wraps a raw resolve function with an LRU cache keyed by databaseId:apiId.
6
- * Each loader gets its own independent cache with configurable TTL and
7
- * max entries.
6
+ * Each loader gets its own independent cache with a configurable hard TTL and
7
+ * maximum size.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.createModuleLoader = createModuleLoader;
@@ -17,53 +17,82 @@ function createModuleLoader(opts) {
17
17
  const cache = new lru_cache_1.LRUCache({
18
18
  max: opts.max ?? DEFAULT_MAX,
19
19
  ttl: opts.ttlMs ?? DEFAULT_TTL_MS,
20
- updateAgeOnGet: true,
20
+ updateAgeOnGet: false,
21
21
  allowStale: false,
22
22
  });
23
+ const pending = new Map();
23
24
  return {
24
25
  name: opts.name,
25
26
  async resolve(ctx) {
26
27
  const key = ctx.apiId ? `${ctx.databaseId}:${ctx.apiId}` : ctx.databaseId;
27
- if (cache.has(key)) {
28
+ const cached = cache.get(key);
29
+ if (cached !== undefined) {
28
30
  log.debug(`Cache HIT databaseId=${key}`);
29
- return cache.get(key);
31
+ return cached;
32
+ }
33
+ const existing = pending.get(key);
34
+ if (existing && !existing.invalidated) {
35
+ log.debug(`Cache COALESCE databaseId=${key}`);
36
+ return existing.promise;
30
37
  }
31
38
  log.debug(`Cache MISS databaseId=${key}, resolving`);
32
39
  // "Not provisioned" is expressed by the loader returning undefined, or
33
40
  // by the module's tables not existing at all (42P01 undefined_table).
34
41
  // Any other resolution error (bad query, ambiguous config) propagates —
35
42
  // never silently coerced into "module absent".
36
- try {
37
- const value = await opts.resolve(ctx);
38
- cache.set(key, value);
39
- return value;
40
- }
41
- catch (e) {
42
- if (e.code === '42P01') {
43
- log.debug(`Module tables absent for databaseId=${key}: ${e.message}`);
44
- cache.set(key, undefined);
45
- return undefined;
43
+ const resolution = {
44
+ databaseId: ctx.databaseId,
45
+ invalidated: false,
46
+ promise: Promise.resolve(undefined)
47
+ };
48
+ resolution.promise = Promise.resolve().then(async () => {
49
+ try {
50
+ const value = await opts.resolve(ctx);
51
+ // Keep absence uncached so subsequent calls can discover new config.
52
+ if (!resolution.invalidated && value !== undefined) {
53
+ cache.set(key, value);
54
+ }
55
+ return value;
46
56
  }
47
- log.warn(`Failed to resolve databaseId=${key}: ${e.message}`);
48
- throw e;
49
- }
57
+ catch (e) {
58
+ if (e.code === '42P01') {
59
+ log.debug(`Module tables absent for databaseId=${key}: ${e.message}`);
60
+ return undefined;
61
+ }
62
+ log.warn(`Failed to resolve databaseId=${key}: ${e.message}`);
63
+ throw e;
64
+ }
65
+ finally {
66
+ if (pending.get(key) === resolution) {
67
+ pending.delete(key);
68
+ }
69
+ }
70
+ });
71
+ pending.set(key, resolution);
72
+ return resolution.promise;
50
73
  },
51
74
  invalidate(databaseId) {
52
- if (databaseId) {
53
- // Clear the plain databaseId key and any composite databaseId:apiId keys
54
- let cleared = 0;
55
- for (const k of cache.keys()) {
56
- if (k === databaseId || k.startsWith(`${databaseId}:`)) {
57
- cache.delete(k);
75
+ if (!databaseId) {
76
+ const previousSize = cache.size;
77
+ cache.clear();
78
+ for (const resolution of pending.values()) {
79
+ resolution.invalidated = true;
80
+ }
81
+ log.debug(`Invalidated all entries (was size=${previousSize})`);
82
+ return;
83
+ }
84
+ let cleared = 0;
85
+ for (const key of cache.keys()) {
86
+ if (key === databaseId || key.startsWith(`${databaseId}:`)) {
87
+ if (cache.delete(key))
58
88
  cleared++;
59
- }
60
89
  }
61
- log.debug(`Invalidated ${cleared} entries for databaseId=${databaseId}`);
62
90
  }
63
- else {
64
- cache.clear();
65
- log.debug(`Invalidated all entries (was size=${cache.size})`);
91
+ for (const resolution of pending.values()) {
92
+ if (resolution.databaseId === databaseId)
93
+ resolution.invalidated = true;
66
94
  }
95
+ log.debug(`Invalidated ${cleared} entries for databaseId=${databaseId}`);
67
96
  },
68
97
  get cacheSize() {
69
98
  return cache.size;
@@ -10,6 +10,7 @@
10
10
  * - corsOrigins (routing-plane cors_settings)
11
11
  * - databaseSettings(routing-plane database_settings)
12
12
  * - requestProtection (routing-plane database_settings/api_settings bounds)
13
+ * - standing (metaschema_public.database suspended_at/suspended_reason, 5s TTL)
13
14
  * - pubkeyChallengeSettings (routing-plane pubkey_settings)
14
15
  * - webauthnSettings(routing-plane webauthn_settings)
15
16
  * - authSettings (metaschema_modules_public.sessions_module → tenant DB)
@@ -51,5 +52,6 @@ export { llmLoader } from './llm';
51
52
  export { pubkeyLoader } from './pubkey';
52
53
  export { requestProtectionLoader } from './request-protection';
53
54
  export { rlsLoader } from './rls';
55
+ export { DATABASE_STANDING_SQL, DATABASE_STANDING_TTL_MS, standingLoader } from './standing';
54
56
  export { webauthnLoader } from './webauthn';
55
57
  export declare function createDefaultRegistry(): import("./registry").LoaderRegistry;
package/loaders/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * - corsOrigins (routing-plane cors_settings)
12
12
  * - databaseSettings(routing-plane database_settings)
13
13
  * - requestProtection (routing-plane database_settings/api_settings bounds)
14
+ * - standing (metaschema_public.database suspended_at/suspended_reason, 5s TTL)
14
15
  * - pubkeyChallengeSettings (routing-plane pubkey_settings)
15
16
  * - webauthnSettings(routing-plane webauthn_settings)
16
17
  * - authSettings (metaschema_modules_public.sessions_module → tenant DB)
@@ -33,7 +34,7 @@
33
34
  * registry.register(myLoader);
34
35
  */
35
36
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.webauthnLoader = exports.rlsLoader = exports.requestProtectionLoader = exports.pubkeyLoader = exports.llmLoader = exports.inferenceLogLoader = exports.requireIdentityProvider = exports.identityProvidersLoader = exports.eventsLoader = exports.databaseSettingsLoader = exports.corsLoader = exports.computeLoader = exports.billingLoader = exports.authSurfaceLoader = exports.authSettingsLoader = exports.agentChatLoader = exports.createLoaderRegistry = exports.createModuleLoader = exports.requireDatabaseId = void 0;
37
+ exports.webauthnLoader = exports.standingLoader = exports.DATABASE_STANDING_TTL_MS = exports.DATABASE_STANDING_SQL = exports.rlsLoader = exports.requestProtectionLoader = exports.pubkeyLoader = exports.llmLoader = exports.inferenceLogLoader = exports.requireIdentityProvider = exports.identityProvidersLoader = exports.eventsLoader = exports.databaseSettingsLoader = exports.corsLoader = exports.computeLoader = exports.billingLoader = exports.authSurfaceLoader = exports.authSettingsLoader = exports.agentChatLoader = exports.createLoaderRegistry = exports.createModuleLoader = exports.requireDatabaseId = void 0;
37
38
  exports.createDefaultRegistry = createDefaultRegistry;
38
39
  var types_1 = require("./types");
39
40
  Object.defineProperty(exports, "requireDatabaseId", { enumerable: true, get: function () { return types_1.requireDatabaseId; } });
@@ -71,6 +72,10 @@ var request_protection_1 = require("./request-protection");
71
72
  Object.defineProperty(exports, "requestProtectionLoader", { enumerable: true, get: function () { return request_protection_1.requestProtectionLoader; } });
72
73
  var rls_1 = require("./rls");
73
74
  Object.defineProperty(exports, "rlsLoader", { enumerable: true, get: function () { return rls_1.rlsLoader; } });
75
+ var standing_1 = require("./standing");
76
+ Object.defineProperty(exports, "DATABASE_STANDING_SQL", { enumerable: true, get: function () { return standing_1.DATABASE_STANDING_SQL; } });
77
+ Object.defineProperty(exports, "DATABASE_STANDING_TTL_MS", { enumerable: true, get: function () { return standing_1.DATABASE_STANDING_TTL_MS; } });
78
+ Object.defineProperty(exports, "standingLoader", { enumerable: true, get: function () { return standing_1.standingLoader; } });
74
79
  var webauthn_1 = require("./webauthn");
75
80
  Object.defineProperty(exports, "webauthnLoader", { enumerable: true, get: function () { return webauthn_1.webauthnLoader; } });
76
81
  /**
@@ -90,6 +95,7 @@ const pubkey_2 = require("./pubkey");
90
95
  const registry_2 = require("./registry");
91
96
  const request_protection_2 = require("./request-protection");
92
97
  const rls_2 = require("./rls");
98
+ const standing_2 = require("./standing");
93
99
  const webauthn_2 = require("./webauthn");
94
100
  function createDefaultRegistry() {
95
101
  const registry = (0, registry_2.createLoaderRegistry)();
@@ -107,5 +113,6 @@ function createDefaultRegistry() {
107
113
  registry.register(compute_2.computeLoader);
108
114
  registry.register(events_2.eventsLoader);
109
115
  registry.register(request_protection_2.requestProtectionLoader);
116
+ registry.register(standing_2.standingLoader);
110
117
  return registry;
111
118
  }
@@ -9,8 +9,8 @@
9
9
  * parallel. Useful for pre-warming or migration from the monolithic
10
10
  * svcCache pattern.
11
11
  *
12
- * Each loader's result is independently cached per databaseId — resolving
13
- * one module never invalidates another.
12
+ * Each loader's result is independently cached per databaseId and optional
13
+ * apiId — resolving one module never invalidates another.
14
14
  */
15
15
  import type { LoaderContext, ModuleLoader } from './types';
16
16
  export interface LoaderRegistry {
@@ -19,8 +19,8 @@ export interface LoaderRegistry {
19
19
  /**
20
20
  * Resolve a single loader by name (lazy, on-demand).
21
21
  * Returns undefined if the loader isn't registered or the module
22
- * isn't provisioned for this database. Results are cached per databaseId
23
- * inside the loader's own LRU — repeated calls are cheap.
22
+ * isn't provisioned for this database. Results are cached per databaseId and
23
+ * optional apiId inside the loader's own LRU — repeated calls are cheap.
24
24
  */
25
25
  resolve<T = unknown>(name: string, ctx: LoaderContext): Promise<T | undefined>;
26
26
  /** Resolve all registered loaders in parallel (eager, for migration/pre-warm). */
@@ -10,8 +10,8 @@
10
10
  * parallel. Useful for pre-warming or migration from the monolithic
11
11
  * svcCache pattern.
12
12
  *
13
- * Each loader's result is independently cached per databaseId — resolving
14
- * one module never invalidates another.
13
+ * Each loader's result is independently cached per databaseId and optional
14
+ * apiId — resolving one module never invalidates another.
15
15
  */
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.createLoaderRegistry = createLoaderRegistry;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Database Standing Loader
3
+ *
4
+ * Whether a database may be served right now. The state is the two
5
+ * system-controlled columns on `metaschema_public.database`
6
+ * (`suspended_at`, `suspended_reason`) that billing and platform admins write;
7
+ * every lane reads it through this loader so the policy behind "suspended" can
8
+ * change without touching a serving path.
9
+ *
10
+ * The TTL is deliberately short: it bounds how long an established client
11
+ * keeps being served after its database is suspended.
12
+ *
13
+ * A plane without `metaschema_public.database` resolves to `undefined` like any
14
+ * other absent module (the factory's undefined_table handling); any other read
15
+ * failure propagates so the caller fails closed. A database the plane does not
16
+ * know is *not* servable — a request pinned to it has nothing to run against.
17
+ */
18
+ import type { DatabaseStanding } from '../types';
19
+ import type { ModuleLoader } from './types';
20
+ export declare const DATABASE_STANDING_SQL = "\n SELECT suspended_at, suspended_reason\n FROM metaschema_public.database\n WHERE id = $1\n";
21
+ export declare const DATABASE_STANDING_TTL_MS = 5000;
22
+ export declare const standingLoader: ModuleLoader<DatabaseStanding>;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ /**
3
+ * Database Standing Loader
4
+ *
5
+ * Whether a database may be served right now. The state is the two
6
+ * system-controlled columns on `metaschema_public.database`
7
+ * (`suspended_at`, `suspended_reason`) that billing and platform admins write;
8
+ * every lane reads it through this loader so the policy behind "suspended" can
9
+ * change without touching a serving path.
10
+ *
11
+ * The TTL is deliberately short: it bounds how long an established client
12
+ * keeps being served after its database is suspended.
13
+ *
14
+ * A plane without `metaschema_public.database` resolves to `undefined` like any
15
+ * other absent module (the factory's undefined_table handling); any other read
16
+ * failure propagates so the caller fails closed. A database the plane does not
17
+ * know is *not* servable — a request pinned to it has nothing to run against.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.standingLoader = exports.DATABASE_STANDING_TTL_MS = exports.DATABASE_STANDING_SQL = void 0;
21
+ const create_loader_1 = require("./create-loader");
22
+ // ─── SQL ────────────────────────────────────────────────────────────────────
23
+ exports.DATABASE_STANDING_SQL = `
24
+ SELECT suspended_at, suspended_reason
25
+ FROM metaschema_public.database
26
+ WHERE id = $1
27
+ `;
28
+ exports.DATABASE_STANDING_TTL_MS = 5_000;
29
+ // ─── Loader ─────────────────────────────────────────────────────────────────
30
+ exports.standingLoader = (0, create_loader_1.createModuleLoader)({
31
+ name: 'standing',
32
+ ttlMs: exports.DATABASE_STANDING_TTL_MS,
33
+ async resolve(ctx) {
34
+ const { routingPool, databaseId } = ctx;
35
+ const { rows } = await routingPool.query(exports.DATABASE_STANDING_SQL, [databaseId]);
36
+ const row = rows[0];
37
+ if (!row)
38
+ return { exists: false, suspended: false, suspendedAt: null, reason: null };
39
+ const suspendedAt = row.suspended_at === null ? null : new Date(row.suspended_at);
40
+ return {
41
+ exists: true,
42
+ suspended: suspendedAt !== null,
43
+ suspendedAt,
44
+ reason: row.suspended_reason
45
+ };
46
+ }
47
+ });
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Module Loader Types
3
3
  *
4
- * A ModuleLoader is a per-database cached lookup that resolves config
5
- * from the routing DB or tenant DB. Each loader owns its own LRU cache
6
- * keyed by databaseId, with independent TTL and eviction.
4
+ * A ModuleLoader is a cached lookup that resolves config from the routing DB
5
+ * or tenant DB. Each loader owns an independent, bounded LRU keyed by
6
+ * databaseId and optional apiId.
7
7
  *
8
8
  * Loaders are registered in a LoaderRegistry and resolved in parallel
9
9
  * during context building. The result is a typed modules map on
@@ -48,15 +48,18 @@ export interface LoaderContext {
48
48
  dbname: string;
49
49
  }
50
50
  /**
51
- * A single module loader. Encapsulates the SQL query, type transform,
52
- * and per-databaseId LRU cache for one piece of per-database config.
51
+ * A single module loader. Encapsulates the SQL query, type transform, and
52
+ * per-database/API LRU cache for one piece of per-database config.
53
53
  */
54
54
  export interface ModuleLoader<T = unknown> {
55
55
  /** Unique name (used in log prefix and as the key in the modules map) */
56
56
  readonly name: string;
57
57
  /** Resolve the module config for a given database. Returns undefined if not provisioned. */
58
58
  resolve(ctx: LoaderContext): Promise<T | undefined>;
59
- /** Invalidate the cache for one database (or all databases if omitted) */
59
+ /**
60
+ * Invalidate one logical database, including all of its API entries.
61
+ * Omitting the database ID clears everything.
62
+ */
60
63
  invalidate(databaseId?: string): void;
61
64
  /** Current number of cached entries */
62
65
  readonly cacheSize: number;
package/loaders/types.js CHANGED
@@ -2,9 +2,9 @@
2
2
  /**
3
3
  * Module Loader Types
4
4
  *
5
- * A ModuleLoader is a per-database cached lookup that resolves config
6
- * from the routing DB or tenant DB. Each loader owns its own LRU cache
7
- * keyed by databaseId, with independent TTL and eviction.
5
+ * A ModuleLoader is a cached lookup that resolves config from the routing DB
6
+ * or tenant DB. Each loader owns an independent, bounded LRU keyed by
7
+ * databaseId and optional apiId.
8
8
  *
9
9
  * Loaders are registered in a LoaderRegistry and resolved in parallel
10
10
  * during context building. The result is a typed modules map on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/express-context",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "description": "Extractable Express middleware for Constructive tenant context — domain resolution, JWT auth, pgSettings, and withPgClient",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
@@ -30,13 +30,13 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@constructive-io/url-domains": "^2.30.1",
33
- "@pgpmjs/env": "^2.43.4",
33
+ "@pgpmjs/env": "^2.43.6",
34
34
  "@pgpmjs/logger": "^2.25.1",
35
- "@pgpmjs/server-utils": "^3.27.4",
36
- "@pgpmjs/types": "^2.53.2",
35
+ "@pgpmjs/server-utils": "^3.27.6",
36
+ "@pgpmjs/types": "^2.53.4",
37
37
  "lru-cache": "^11.2.7",
38
38
  "pg": "^8.21.0",
39
- "pg-cache": "^3.27.4",
39
+ "pg-cache": "^3.27.6",
40
40
  "pg-query-context": "^2.30.1"
41
41
  },
42
42
  "devDependencies": {
@@ -59,5 +59,5 @@
59
59
  "jwt",
60
60
  "pgSettings"
61
61
  ],
62
- "gitHead": "10d3a11478d17a20c80da5a827f8e8da4facfc80"
62
+ "gitHead": "9bba983b38395277e0400294aaa380e586b049d4"
63
63
  }
package/types.d.ts CHANGED
@@ -221,6 +221,17 @@ export interface LlmConfig {
221
221
  maxTokensPerRequest: number | null;
222
222
  ragContextLimit: number | null;
223
223
  }
224
+ /**
225
+ * Whether a database may be served right now, as read from
226
+ * `metaschema_public.database`. A database the plane does not know is not
227
+ * servable either (`exists: false`).
228
+ */
229
+ export interface DatabaseStanding {
230
+ exists: boolean;
231
+ suspended: boolean;
232
+ suspendedAt: Date | null;
233
+ reason: string | null;
234
+ }
224
235
  /**
225
236
  * Maps built-in loader names to their resolved types.
226
237
  *
@@ -245,6 +256,7 @@ export interface BuiltinModuleMap {
245
256
  compute: ComputeConfig;
246
257
  events: EventsConfig;
247
258
  requestProtection: RequestProtection;
259
+ standing: DatabaseStanding;
248
260
  }
249
261
  /**
250
262
  * Callback for executing queries within a tenant-scoped transaction