@constructive-io/express-context 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -136,6 +136,21 @@ Common issues and solutions for pgpm, PostgreSQL, and testing.
136
136
  * [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
137
137
  * [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
138
138
 
139
+ ### 📚 Documentation & Skills
140
+
141
+ * [constructive-skills](https://github.com/constructive-io/constructive-skills): **📖 Platform documentation and AI agent skills** — feature catalog, blueprint reference, SDK guides (i18n, billing, limits, events, uploads, security, entities, search, AI), and deployment guides.
142
+
143
+ Install skills for AI coding agents:
144
+
145
+ ```bash
146
+ # All platform skills (security, blueprints, codegen, billing, etc.)
147
+ npx skills add constructive-io/constructive-skills
148
+
149
+ # Individual repo skills (pgpm, testing, CLI, search, etc.)
150
+ npx skills add https://github.com/constructive-io/constructive --skill pgpm
151
+ npx skills add https://github.com/constructive-io/constructive --skill constructive-testing
152
+ ```
153
+
139
154
  ## Credits
140
155
 
141
156
  **🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
@@ -0,0 +1,56 @@
1
+ /**
2
+ * billing-client — Shared billing operations for any Express-based service
3
+ *
4
+ * Provides a unified `BillingClient` that encapsulates:
5
+ * - `checkQuota(meterSlug, amount)` — pre-check billing quota
6
+ * - `recordUsage(meterSlug, amount, metadata)` — post-record usage
7
+ * - `logInference(entry)` — write to usage_log_inference table
8
+ *
9
+ * Both `agentic-server` and `graphile-llm` should delegate to this client
10
+ * instead of implementing their own SQL calls.
11
+ *
12
+ * Graceful: all operations are no-ops if the billing/inference_log modules
13
+ * are not provisioned. Errors are logged and swallowed (non-fatal).
14
+ */
15
+ import type { BillingConfig, InferenceLogConfig, WithPgClient } from './types';
16
+ export interface InferenceLogEntry {
17
+ entityId: string;
18
+ actorId: string | null;
19
+ model: string;
20
+ provider: string | null;
21
+ service: string;
22
+ operation: string;
23
+ inputTokens: number;
24
+ outputTokens: number;
25
+ totalTokens: number;
26
+ latencyMs: number;
27
+ status: string;
28
+ cacheReadTokens?: number | null;
29
+ cacheWriteTokens?: number | null;
30
+ ragEnabled?: boolean;
31
+ chunksRetrieved?: number | null;
32
+ embeddingModel?: string | null;
33
+ embeddingLatencyMs?: number | null;
34
+ errorType?: string | null;
35
+ rawUsage?: Record<string, unknown> | null;
36
+ }
37
+ export interface BillingClient {
38
+ /**
39
+ * Check if the entity has sufficient quota for the requested amount.
40
+ * Returns true if allowed, false if quota is exceeded.
41
+ *
42
+ * Gracefully returns true if billing is not provisioned or errors.
43
+ */
44
+ checkQuota(meterSlug: string, amount?: number): Promise<boolean>;
45
+ /**
46
+ * Record usage after a successful operation.
47
+ * Gracefully skips if billing is not provisioned or errors.
48
+ */
49
+ recordUsage(meterSlug: string, amount: number, metadata?: Record<string, unknown>): Promise<void>;
50
+ /**
51
+ * Write a row to the usage_log_inference table.
52
+ * Gracefully skips if inference_log_module is not provisioned or errors.
53
+ */
54
+ logInference(entry: InferenceLogEntry): Promise<void>;
55
+ }
56
+ export declare function createBillingClient(withPgClient: WithPgClient, entityId: string, billing: BillingConfig | null, inferenceLog: InferenceLogConfig | null): BillingClient;
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ /**
3
+ * billing-client — Shared billing operations for any Express-based service
4
+ *
5
+ * Provides a unified `BillingClient` that encapsulates:
6
+ * - `checkQuota(meterSlug, amount)` — pre-check billing quota
7
+ * - `recordUsage(meterSlug, amount, metadata)` — post-record usage
8
+ * - `logInference(entry)` — write to usage_log_inference table
9
+ *
10
+ * Both `agentic-server` and `graphile-llm` should delegate to this client
11
+ * instead of implementing their own SQL calls.
12
+ *
13
+ * Graceful: all operations are no-ops if the billing/inference_log modules
14
+ * are not provisioned. Errors are logged and swallowed (non-fatal).
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.createBillingClient = createBillingClient;
18
+ const logger_1 = require("@pgpmjs/logger");
19
+ const log = new logger_1.Logger('billing-client');
20
+ // ─── Implementation ─────────────────────────────────────────────────────────
21
+ function createBillingClient(withPgClient, entityId, billing, inferenceLog) {
22
+ return {
23
+ async checkQuota(meterSlug, amount = 1) {
24
+ if (!billing)
25
+ return true;
26
+ try {
27
+ return await withPgClient(async (client) => {
28
+ const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
29
+ const result = await client.query(sql, [meterSlug, entityId, amount]);
30
+ return result.rows[0]?.allowed !== false;
31
+ });
32
+ }
33
+ catch (e) {
34
+ const message = e instanceof Error ? e.message : String(e);
35
+ log.warn(`check_billing_quota failed (allowing): ${message}`);
36
+ return true;
37
+ }
38
+ },
39
+ async recordUsage(meterSlug, amount, metadata) {
40
+ if (!billing)
41
+ return;
42
+ try {
43
+ await withPgClient(async (client) => {
44
+ const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
45
+ await client.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata ?? {})]);
46
+ });
47
+ }
48
+ catch (e) {
49
+ const message = e instanceof Error ? e.message : String(e);
50
+ log.warn(`record_usage failed (non-fatal): ${message}`);
51
+ }
52
+ },
53
+ async logInference(entry) {
54
+ if (!inferenceLog)
55
+ return;
56
+ try {
57
+ await withPgClient(async (client) => {
58
+ await client.query(`INSERT INTO "${inferenceLog.schema}"."${inferenceLog.tableName}"
59
+ (entity_id, actor_id, model, provider, service, operation,
60
+ input_tokens, output_tokens, total_tokens, latency_ms, status,
61
+ cache_read_tokens, cache_write_tokens,
62
+ rag_enabled, chunks_retrieved,
63
+ embedding_model, embedding_latency_ms,
64
+ error_type, raw_usage)
65
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, [
66
+ entry.entityId, entry.actorId, entry.model,
67
+ entry.provider, entry.service, entry.operation,
68
+ entry.inputTokens, entry.outputTokens, entry.totalTokens,
69
+ entry.latencyMs, entry.status,
70
+ entry.cacheReadTokens ?? null, entry.cacheWriteTokens ?? null,
71
+ entry.ragEnabled ?? false, entry.chunksRetrieved ?? null,
72
+ entry.embeddingModel ?? null, entry.embeddingLatencyMs ?? null,
73
+ entry.errorType ?? null,
74
+ entry.rawUsage ? JSON.stringify(entry.rawUsage) : null
75
+ ]);
76
+ });
77
+ }
78
+ catch (e) {
79
+ const message = e instanceof Error ? e.message : String(e);
80
+ log.warn(`inference log INSERT failed (non-fatal): ${message}`);
81
+ }
82
+ }
83
+ };
84
+ }
package/context.d.ts CHANGED
@@ -13,8 +13,8 @@
13
13
  * The result is a single `req.constructive` object that any downstream
14
14
  * route handler can use for tenant-scoped database operations.
15
15
  */
16
- import type { Request, RequestHandler } from 'express';
17
16
  import type { PgpmOptions } from '@pgpmjs/types';
17
+ import type { Request, RequestHandler } from 'express';
18
18
  import type { LoaderRegistry } from './loaders/registry';
19
19
  import type { ConstructiveContext } from './types';
20
20
  export interface ContextMiddlewareOptions {
package/context.js CHANGED
@@ -18,6 +18,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.buildContext = buildContext;
19
19
  exports.createContextMiddleware = createContextMiddleware;
20
20
  const pg_cache_1 = require("pg-cache");
21
+ const billing_client_1 = require("./billing-client");
21
22
  const pg_client_1 = require("./pg-client");
22
23
  const pg_settings_1 = require("./pg-settings");
23
24
  /**
@@ -54,11 +55,11 @@ function buildContext(req, opts = {}) {
54
55
  api,
55
56
  token,
56
57
  requestId,
57
- clientIp: req.clientIp,
58
+ clientIp: req.clientIp
58
59
  });
59
60
  const tenantPool = (0, pg_cache_1.getPgPool)({
60
61
  ...opts.pg,
61
- database: api.dbname,
62
+ database: api.dbname
62
63
  });
63
64
  // Build loader context (if registry provided and databaseId known)
64
65
  let loaderCtx = null;
@@ -69,9 +70,15 @@ function buildContext(req, opts = {}) {
69
70
  tenantPool,
70
71
  databaseId: api.databaseId,
71
72
  apiId: api.apiId,
72
- dbname: api.dbname,
73
+ dbname: api.dbname
73
74
  };
74
75
  }
76
+ const withPgClient = (fn) => (0, pg_client_1.withPgClient)(tenantPool, pgSettings, fn);
77
+ const useModule = createUseModule(opts.loaders, loaderCtx);
78
+ // Lazy-initialized billing client (cached per request)
79
+ let billingClient;
80
+ // Lazy-initialized LLM config (cached per request)
81
+ let llmConfig;
75
82
  return {
76
83
  api,
77
84
  token,
@@ -80,8 +87,34 @@ function buildContext(req, opts = {}) {
80
87
  userId: token?.user_id ?? null,
81
88
  requestId,
82
89
  pool: tenantPool,
83
- withPgClient: (fn) => (0, pg_client_1.withPgClient)(tenantPool, pgSettings, fn),
84
- useModule: createUseModule(opts.loaders, loaderCtx),
90
+ withPgClient,
91
+ useModule,
92
+ async useBilling() {
93
+ if (billingClient !== undefined)
94
+ return billingClient;
95
+ const entityId = token?.entity_id;
96
+ if (!entityId) {
97
+ billingClient = null;
98
+ return null;
99
+ }
100
+ const [billing, inferenceLog] = await Promise.all([
101
+ useModule('billing'),
102
+ useModule('inferenceLog')
103
+ ]);
104
+ if (!billing) {
105
+ billingClient = null;
106
+ return null;
107
+ }
108
+ billingClient = (0, billing_client_1.createBillingClient)(withPgClient, entityId, billing, inferenceLog ?? null);
109
+ return billingClient;
110
+ },
111
+ async useLlm() {
112
+ if (llmConfig !== undefined)
113
+ return llmConfig;
114
+ const resolved = await useModule('llm');
115
+ llmConfig = resolved ?? null;
116
+ return llmConfig;
117
+ }
85
118
  };
86
119
  }
87
120
  /**
@@ -0,0 +1,81 @@
1
+ /**
2
+ * billing-client — Shared billing operations for any Express-based service
3
+ *
4
+ * Provides a unified `BillingClient` that encapsulates:
5
+ * - `checkQuota(meterSlug, amount)` — pre-check billing quota
6
+ * - `recordUsage(meterSlug, amount, metadata)` — post-record usage
7
+ * - `logInference(entry)` — write to usage_log_inference table
8
+ *
9
+ * Both `agentic-server` and `graphile-llm` should delegate to this client
10
+ * instead of implementing their own SQL calls.
11
+ *
12
+ * Graceful: all operations are no-ops if the billing/inference_log modules
13
+ * are not provisioned. Errors are logged and swallowed (non-fatal).
14
+ */
15
+ import { Logger } from '@pgpmjs/logger';
16
+ const log = new Logger('billing-client');
17
+ // ─── Implementation ─────────────────────────────────────────────────────────
18
+ export function createBillingClient(withPgClient, entityId, billing, inferenceLog) {
19
+ return {
20
+ async checkQuota(meterSlug, amount = 1) {
21
+ if (!billing)
22
+ return true;
23
+ try {
24
+ return await withPgClient(async (client) => {
25
+ const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
26
+ const result = await client.query(sql, [meterSlug, entityId, amount]);
27
+ return result.rows[0]?.allowed !== false;
28
+ });
29
+ }
30
+ catch (e) {
31
+ const message = e instanceof Error ? e.message : String(e);
32
+ log.warn(`check_billing_quota failed (allowing): ${message}`);
33
+ return true;
34
+ }
35
+ },
36
+ async recordUsage(meterSlug, amount, metadata) {
37
+ if (!billing)
38
+ return;
39
+ try {
40
+ await withPgClient(async (client) => {
41
+ const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
42
+ await client.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata ?? {})]);
43
+ });
44
+ }
45
+ catch (e) {
46
+ const message = e instanceof Error ? e.message : String(e);
47
+ log.warn(`record_usage failed (non-fatal): ${message}`);
48
+ }
49
+ },
50
+ async logInference(entry) {
51
+ if (!inferenceLog)
52
+ return;
53
+ try {
54
+ await withPgClient(async (client) => {
55
+ await client.query(`INSERT INTO "${inferenceLog.schema}"."${inferenceLog.tableName}"
56
+ (entity_id, actor_id, model, provider, service, operation,
57
+ input_tokens, output_tokens, total_tokens, latency_ms, status,
58
+ cache_read_tokens, cache_write_tokens,
59
+ rag_enabled, chunks_retrieved,
60
+ embedding_model, embedding_latency_ms,
61
+ error_type, raw_usage)
62
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, [
63
+ entry.entityId, entry.actorId, entry.model,
64
+ entry.provider, entry.service, entry.operation,
65
+ entry.inputTokens, entry.outputTokens, entry.totalTokens,
66
+ entry.latencyMs, entry.status,
67
+ entry.cacheReadTokens ?? null, entry.cacheWriteTokens ?? null,
68
+ entry.ragEnabled ?? false, entry.chunksRetrieved ?? null,
69
+ entry.embeddingModel ?? null, entry.embeddingLatencyMs ?? null,
70
+ entry.errorType ?? null,
71
+ entry.rawUsage ? JSON.stringify(entry.rawUsage) : null
72
+ ]);
73
+ });
74
+ }
75
+ catch (e) {
76
+ const message = e instanceof Error ? e.message : String(e);
77
+ log.warn(`inference log INSERT failed (non-fatal): ${message}`);
78
+ }
79
+ }
80
+ };
81
+ }
package/esm/context.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * route handler can use for tenant-scoped database operations.
15
15
  */
16
16
  import { getPgPool } from 'pg-cache';
17
+ import { createBillingClient } from './billing-client';
17
18
  import { withPgClient as withPgClientFn } from './pg-client';
18
19
  import { buildPgSettings } from './pg-settings';
19
20
  /**
@@ -50,11 +51,11 @@ export function buildContext(req, opts = {}) {
50
51
  api,
51
52
  token,
52
53
  requestId,
53
- clientIp: req.clientIp,
54
+ clientIp: req.clientIp
54
55
  });
55
56
  const tenantPool = getPgPool({
56
57
  ...opts.pg,
57
- database: api.dbname,
58
+ database: api.dbname
58
59
  });
59
60
  // Build loader context (if registry provided and databaseId known)
60
61
  let loaderCtx = null;
@@ -65,9 +66,15 @@ export function buildContext(req, opts = {}) {
65
66
  tenantPool,
66
67
  databaseId: api.databaseId,
67
68
  apiId: api.apiId,
68
- dbname: api.dbname,
69
+ dbname: api.dbname
69
70
  };
70
71
  }
72
+ const withPgClient = (fn) => withPgClientFn(tenantPool, pgSettings, fn);
73
+ const useModule = createUseModule(opts.loaders, loaderCtx);
74
+ // Lazy-initialized billing client (cached per request)
75
+ let billingClient;
76
+ // Lazy-initialized LLM config (cached per request)
77
+ let llmConfig;
71
78
  return {
72
79
  api,
73
80
  token,
@@ -76,8 +83,34 @@ export function buildContext(req, opts = {}) {
76
83
  userId: token?.user_id ?? null,
77
84
  requestId,
78
85
  pool: tenantPool,
79
- withPgClient: (fn) => withPgClientFn(tenantPool, pgSettings, fn),
80
- useModule: createUseModule(opts.loaders, loaderCtx),
86
+ withPgClient,
87
+ useModule,
88
+ async useBilling() {
89
+ if (billingClient !== undefined)
90
+ return billingClient;
91
+ const entityId = token?.entity_id;
92
+ if (!entityId) {
93
+ billingClient = null;
94
+ return null;
95
+ }
96
+ const [billing, inferenceLog] = await Promise.all([
97
+ useModule('billing'),
98
+ useModule('inferenceLog')
99
+ ]);
100
+ if (!billing) {
101
+ billingClient = null;
102
+ return null;
103
+ }
104
+ billingClient = createBillingClient(withPgClient, entityId, billing, inferenceLog ?? null);
105
+ return billingClient;
106
+ },
107
+ async useLlm() {
108
+ if (llmConfig !== undefined)
109
+ return llmConfig;
110
+ const resolved = await useModule('llm');
111
+ llmConfig = resolved ?? null;
112
+ return llmConfig;
113
+ }
81
114
  };
82
115
  }
83
116
  /**
package/esm/index.js CHANGED
@@ -34,12 +34,13 @@
34
34
  * });
35
35
  * ```
36
36
  */
37
+ export { createBillingClient } from './billing-client';
37
38
  export { buildPgSettings } from './pg-settings';
38
39
  // withPgClient helper
39
40
  export { withPgClient } from './pg-client';
40
41
  // Request ID middleware
41
42
  export { requestIdMiddleware } from './request-id';
42
43
  export { buildContext, createContextMiddleware } from './context';
43
- export { agentChatLoader, authSettingsLoader, billingLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, inferenceLogLoader, pubkeyLoader, rlsLoader, webauthnLoader, } from './loaders';
44
+ export { agentChatLoader, authSettingsLoader, billingLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, inferenceLogLoader, pubkeyLoader, rlsLoader, llmLoader, webauthnLoader, } from './loaders';
44
45
  // Side-effect: Express type augmentation
45
46
  import './types';
@@ -19,7 +19,8 @@ const DATABASE_SETTINGS_SQL = `
19
19
  COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
20
20
  COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm,
21
21
  COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime,
22
- COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk
22
+ COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk,
23
+ COALESCE(aps.enable_i18n, ds.enable_i18n) AS resolved_enable_i18n
23
24
  FROM services_public.database_settings ds
24
25
  LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
25
26
  WHERE ds.database_id = $1
@@ -47,6 +48,7 @@ export const databaseSettingsLoader = createModuleLoader({
47
48
  enableLlm: row.resolved_enable_llm,
48
49
  enableRealtime: row.resolved_enable_realtime,
49
50
  enableBulk: row.resolved_enable_bulk,
51
+ enableI18n: row.resolved_enable_i18n,
50
52
  };
51
53
  },
52
54
  });
@@ -37,6 +37,7 @@ export { authSettingsLoader } from './auth-settings';
37
37
  export { billingLoader } from './billing';
38
38
  export { inferenceLogLoader } from './inference-log';
39
39
  export { agentChatLoader } from './agent-chat';
40
+ export { llmLoader } from './llm';
40
41
  /**
41
42
  * Convenience: create a registry pre-loaded with all built-in loaders.
42
43
  */
@@ -50,6 +51,7 @@ import { authSettingsLoader } from './auth-settings';
50
51
  import { billingLoader } from './billing';
51
52
  import { inferenceLogLoader } from './inference-log';
52
53
  import { agentChatLoader } from './agent-chat';
54
+ import { llmLoader } from './llm';
53
55
  export function createDefaultRegistry() {
54
56
  const registry = createLoaderRegistry();
55
57
  registry.register(rlsLoader);
@@ -61,5 +63,6 @@ export function createDefaultRegistry() {
61
63
  registry.register(billingLoader);
62
64
  registry.register(inferenceLogLoader);
63
65
  registry.register(agentChatLoader);
66
+ registry.register(llmLoader);
64
67
  return registry;
65
68
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * LLM Module Loader
3
+ *
4
+ * Resolves per-database LLM provider config from metaschema_modules_public.llm_module.
5
+ * Returns embedding and chat provider settings (model, base URL, dimensions, limits).
6
+ *
7
+ * When the llm_module table doesn't exist yet (pre-migration), the loader
8
+ * gracefully returns undefined — callers fall back to env vars.
9
+ */
10
+ import { createModuleLoader } from './create-loader';
11
+ // ─── SQL ────────────────────────────────────────────────────────────────────
12
+ const LLM_MODULE_SQL = `
13
+ SELECT
14
+ lm.embedding_provider,
15
+ lm.embedding_model,
16
+ lm.embedding_base_url,
17
+ lm.embedding_dimensions,
18
+ lm.chat_provider,
19
+ lm.chat_model,
20
+ lm.chat_base_url,
21
+ lm.rate_limit_rpm,
22
+ lm.max_tokens_per_request,
23
+ lm.rag_context_limit
24
+ FROM metaschema_modules_public.llm_module lm
25
+ WHERE lm.database_id = $1
26
+ LIMIT 1
27
+ `;
28
+ // ─── Loader ─────────────────────────────────────────────────────────────────
29
+ export const llmLoader = createModuleLoader({
30
+ name: 'llm',
31
+ ttlMs: 5 * 60_000,
32
+ async resolve(ctx) {
33
+ const { tenantPool, databaseId } = ctx;
34
+ const result = await tenantPool.query(LLM_MODULE_SQL, [databaseId]);
35
+ const row = result.rows[0];
36
+ if (!row)
37
+ return undefined;
38
+ return {
39
+ embeddingProvider: row.embedding_provider,
40
+ embeddingModel: row.embedding_model,
41
+ embeddingBaseUrl: row.embedding_base_url,
42
+ embeddingDimensions: row.embedding_dimensions,
43
+ chatProvider: row.chat_provider,
44
+ chatModel: row.chat_model,
45
+ chatBaseUrl: row.chat_base_url,
46
+ rateLimitRpm: row.rate_limit_rpm,
47
+ maxTokensPerRequest: row.max_tokens_per_request,
48
+ ragContextLimit: row.rag_context_limit,
49
+ };
50
+ },
51
+ });
package/index.d.ts CHANGED
@@ -34,7 +34,9 @@
34
34
  * });
35
35
  * ```
36
36
  */
37
- export type { AgentChatConfig, ApiConfigResult, ApiError, ApiModule, ApiStructure, AuthSettings, BillingConfig, BuiltinModuleMap, ConstructiveAPIToken, ConstructiveContext, CorsModuleData, DatabaseSettings, GenericModuleData, InferenceLogConfig, PublicKeyChallengeData, PubkeyChallengeSettings, RlsModule, WebauthnSettings, WithPgClient, } from './types';
37
+ export type { AgentChatConfig, ApiConfigResult, ApiError, ApiModule, ApiStructure, AuthSettings, BillingConfig, BuiltinModuleMap, ConstructiveAPIToken, ConstructiveContext, CorsModuleData, DatabaseSettings, GenericModuleData, InferenceLogConfig, LlmConfig, PublicKeyChallengeData, PubkeyChallengeSettings, RlsModule, WebauthnSettings, WithPgClient, } from './types';
38
+ export type { BillingClient, InferenceLogEntry } from './billing-client';
39
+ export { createBillingClient } from './billing-client';
38
40
  export type { PgSettingsInput } from './pg-settings';
39
41
  export { buildPgSettings } from './pg-settings';
40
42
  export { withPgClient } from './pg-client';
@@ -42,5 +44,5 @@ export { requestIdMiddleware } from './request-id';
42
44
  export type { ContextMiddlewareOptions } from './context';
43
45
  export { buildContext, createContextMiddleware } from './context';
44
46
  export type { CreateLoaderOptions, LoaderContext, LoaderRegistry, ModuleLoader, } from './loaders';
45
- export { agentChatLoader, authSettingsLoader, billingLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, inferenceLogLoader, pubkeyLoader, rlsLoader, webauthnLoader, } from './loaders';
47
+ export { agentChatLoader, authSettingsLoader, billingLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, inferenceLogLoader, pubkeyLoader, rlsLoader, llmLoader, webauthnLoader, } from './loaders';
46
48
  import './types';
package/index.js CHANGED
@@ -36,7 +36,9 @@
36
36
  * ```
37
37
  */
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.webauthnLoader = exports.rlsLoader = exports.pubkeyLoader = exports.inferenceLogLoader = exports.databaseSettingsLoader = exports.createModuleLoader = exports.createLoaderRegistry = exports.createDefaultRegistry = exports.corsLoader = exports.billingLoader = exports.authSettingsLoader = exports.agentChatLoader = exports.createContextMiddleware = exports.buildContext = exports.requestIdMiddleware = exports.withPgClient = exports.buildPgSettings = void 0;
39
+ exports.webauthnLoader = exports.llmLoader = exports.rlsLoader = exports.pubkeyLoader = exports.inferenceLogLoader = exports.databaseSettingsLoader = exports.createModuleLoader = exports.createLoaderRegistry = exports.createDefaultRegistry = exports.corsLoader = exports.billingLoader = exports.authSettingsLoader = exports.agentChatLoader = exports.createContextMiddleware = exports.buildContext = exports.requestIdMiddleware = exports.withPgClient = exports.buildPgSettings = exports.createBillingClient = void 0;
40
+ var billing_client_1 = require("./billing-client");
41
+ Object.defineProperty(exports, "createBillingClient", { enumerable: true, get: function () { return billing_client_1.createBillingClient; } });
40
42
  var pg_settings_1 = require("./pg-settings");
41
43
  Object.defineProperty(exports, "buildPgSettings", { enumerable: true, get: function () { return pg_settings_1.buildPgSettings; } });
42
44
  // withPgClient helper
@@ -60,6 +62,7 @@ Object.defineProperty(exports, "databaseSettingsLoader", { enumerable: true, get
60
62
  Object.defineProperty(exports, "inferenceLogLoader", { enumerable: true, get: function () { return loaders_1.inferenceLogLoader; } });
61
63
  Object.defineProperty(exports, "pubkeyLoader", { enumerable: true, get: function () { return loaders_1.pubkeyLoader; } });
62
64
  Object.defineProperty(exports, "rlsLoader", { enumerable: true, get: function () { return loaders_1.rlsLoader; } });
65
+ Object.defineProperty(exports, "llmLoader", { enumerable: true, get: function () { return loaders_1.llmLoader; } });
63
66
  Object.defineProperty(exports, "webauthnLoader", { enumerable: true, get: function () { return loaders_1.webauthnLoader; } });
64
67
  // Side-effect: Express type augmentation
65
68
  require("./types");
@@ -22,7 +22,8 @@ const DATABASE_SETTINGS_SQL = `
22
22
  COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
23
23
  COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm,
24
24
  COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime,
25
- COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk
25
+ COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk,
26
+ COALESCE(aps.enable_i18n, ds.enable_i18n) AS resolved_enable_i18n
26
27
  FROM services_public.database_settings ds
27
28
  LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
28
29
  WHERE ds.database_id = $1
@@ -50,6 +51,7 @@ exports.databaseSettingsLoader = (0, create_loader_1.createModuleLoader)({
50
51
  enableLlm: row.resolved_enable_llm,
51
52
  enableRealtime: row.resolved_enable_realtime,
52
53
  enableBulk: row.resolved_enable_bulk,
54
+ enableI18n: row.resolved_enable_i18n,
53
55
  };
54
56
  },
55
57
  });
@@ -39,4 +39,5 @@ export { authSettingsLoader } from './auth-settings';
39
39
  export { billingLoader } from './billing';
40
40
  export { inferenceLogLoader } from './inference-log';
41
41
  export { agentChatLoader } from './agent-chat';
42
+ export { llmLoader } from './llm';
42
43
  export declare function createDefaultRegistry(): import("./registry").LoaderRegistry;
package/loaders/index.js CHANGED
@@ -27,7 +27,7 @@
27
27
  * registry.register(myLoader);
28
28
  */
29
29
  Object.defineProperty(exports, "__esModule", { value: true });
30
- exports.agentChatLoader = exports.inferenceLogLoader = exports.billingLoader = exports.authSettingsLoader = exports.webauthnLoader = exports.pubkeyLoader = exports.databaseSettingsLoader = exports.corsLoader = exports.rlsLoader = exports.createLoaderRegistry = exports.createModuleLoader = void 0;
30
+ exports.llmLoader = exports.agentChatLoader = exports.inferenceLogLoader = exports.billingLoader = exports.authSettingsLoader = exports.webauthnLoader = exports.pubkeyLoader = exports.databaseSettingsLoader = exports.corsLoader = exports.rlsLoader = exports.createLoaderRegistry = exports.createModuleLoader = void 0;
31
31
  exports.createDefaultRegistry = createDefaultRegistry;
32
32
  var create_loader_1 = require("./create-loader");
33
33
  Object.defineProperty(exports, "createModuleLoader", { enumerable: true, get: function () { return create_loader_1.createModuleLoader; } });
@@ -52,6 +52,8 @@ var inference_log_1 = require("./inference-log");
52
52
  Object.defineProperty(exports, "inferenceLogLoader", { enumerable: true, get: function () { return inference_log_1.inferenceLogLoader; } });
53
53
  var agent_chat_1 = require("./agent-chat");
54
54
  Object.defineProperty(exports, "agentChatLoader", { enumerable: true, get: function () { return agent_chat_1.agentChatLoader; } });
55
+ var llm_1 = require("./llm");
56
+ Object.defineProperty(exports, "llmLoader", { enumerable: true, get: function () { return llm_1.llmLoader; } });
55
57
  /**
56
58
  * Convenience: create a registry pre-loaded with all built-in loaders.
57
59
  */
@@ -65,6 +67,7 @@ const auth_settings_2 = require("./auth-settings");
65
67
  const billing_2 = require("./billing");
66
68
  const inference_log_2 = require("./inference-log");
67
69
  const agent_chat_2 = require("./agent-chat");
70
+ const llm_2 = require("./llm");
68
71
  function createDefaultRegistry() {
69
72
  const registry = (0, registry_2.createLoaderRegistry)();
70
73
  registry.register(rls_2.rlsLoader);
@@ -76,5 +79,6 @@ function createDefaultRegistry() {
76
79
  registry.register(billing_2.billingLoader);
77
80
  registry.register(inference_log_2.inferenceLogLoader);
78
81
  registry.register(agent_chat_2.agentChatLoader);
82
+ registry.register(llm_2.llmLoader);
79
83
  return registry;
80
84
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * LLM Module Loader
3
+ *
4
+ * Resolves per-database LLM provider config from metaschema_modules_public.llm_module.
5
+ * Returns embedding and chat provider settings (model, base URL, dimensions, limits).
6
+ *
7
+ * When the llm_module table doesn't exist yet (pre-migration), the loader
8
+ * gracefully returns undefined — callers fall back to env vars.
9
+ */
10
+ import type { LlmConfig } from '../types';
11
+ import type { ModuleLoader } from './types';
12
+ export declare const llmLoader: ModuleLoader<LlmConfig>;
package/loaders/llm.js ADDED
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ /**
3
+ * LLM Module Loader
4
+ *
5
+ * Resolves per-database LLM provider config from metaschema_modules_public.llm_module.
6
+ * Returns embedding and chat provider settings (model, base URL, dimensions, limits).
7
+ *
8
+ * When the llm_module table doesn't exist yet (pre-migration), the loader
9
+ * gracefully returns undefined — callers fall back to env vars.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.llmLoader = void 0;
13
+ const create_loader_1 = require("./create-loader");
14
+ // ─── SQL ────────────────────────────────────────────────────────────────────
15
+ const LLM_MODULE_SQL = `
16
+ SELECT
17
+ lm.embedding_provider,
18
+ lm.embedding_model,
19
+ lm.embedding_base_url,
20
+ lm.embedding_dimensions,
21
+ lm.chat_provider,
22
+ lm.chat_model,
23
+ lm.chat_base_url,
24
+ lm.rate_limit_rpm,
25
+ lm.max_tokens_per_request,
26
+ lm.rag_context_limit
27
+ FROM metaschema_modules_public.llm_module lm
28
+ WHERE lm.database_id = $1
29
+ LIMIT 1
30
+ `;
31
+ // ─── Loader ─────────────────────────────────────────────────────────────────
32
+ exports.llmLoader = (0, create_loader_1.createModuleLoader)({
33
+ name: 'llm',
34
+ ttlMs: 5 * 60_000,
35
+ async resolve(ctx) {
36
+ const { tenantPool, databaseId } = ctx;
37
+ const result = await tenantPool.query(LLM_MODULE_SQL, [databaseId]);
38
+ const row = result.rows[0];
39
+ if (!row)
40
+ return undefined;
41
+ return {
42
+ embeddingProvider: row.embedding_provider,
43
+ embeddingModel: row.embedding_model,
44
+ embeddingBaseUrl: row.embedding_base_url,
45
+ embeddingDimensions: row.embedding_dimensions,
46
+ chatProvider: row.chat_provider,
47
+ chatModel: row.chat_model,
48
+ chatBaseUrl: row.chat_base_url,
49
+ rateLimitRpm: row.rate_limit_rpm,
50
+ maxTokensPerRequest: row.max_tokens_per_request,
51
+ ragContextLimit: row.rag_context_limit,
52
+ };
53
+ },
54
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/express-context",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
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",
@@ -59,5 +59,5 @@
59
59
  "jwt",
60
60
  "pgSettings"
61
61
  ],
62
- "gitHead": "0503916f414a3b8a6f6cbe46e03a59988b2f3cb5"
62
+ "gitHead": "16c2ae1c30850db1d71c021346c9d34361361d8e"
63
63
  }
package/types.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * (PostGraphile, LLM sidecar, etc.) can share the same context.
8
8
  */
9
9
  import type { Pool, PoolClient } from 'pg';
10
+ import type { BillingClient } from './billing-client';
10
11
  export interface CorsModuleData {
11
12
  urls: string[];
12
13
  }
@@ -33,6 +34,7 @@ export interface DatabaseSettings {
33
34
  enableLlm: boolean;
34
35
  enableRealtime: boolean;
35
36
  enableBulk: boolean;
37
+ enableI18n: boolean;
36
38
  }
37
39
  export interface PubkeyChallengeSettings {
38
40
  schema: string;
@@ -135,6 +137,18 @@ export interface AgentChatConfig {
135
137
  messageTableName: string | null;
136
138
  taskTableName: string | null;
137
139
  }
140
+ export interface LlmConfig {
141
+ embeddingProvider: string;
142
+ embeddingModel: string;
143
+ embeddingBaseUrl: string;
144
+ embeddingDimensions: number | null;
145
+ chatProvider: string | null;
146
+ chatModel: string | null;
147
+ chatBaseUrl: string | null;
148
+ rateLimitRpm: number | null;
149
+ maxTokensPerRequest: number | null;
150
+ ragContextLimit: number | null;
151
+ }
138
152
  /**
139
153
  * Maps built-in loader names to their resolved types.
140
154
  *
@@ -153,6 +167,7 @@ export interface BuiltinModuleMap {
153
167
  billing: BillingConfig;
154
168
  inferenceLog: InferenceLogConfig;
155
169
  agentChat: AgentChatConfig;
170
+ llm: LlmConfig;
156
171
  }
157
172
  /**
158
173
  * Callback for executing queries within a tenant-scoped transaction
@@ -202,6 +217,28 @@ export interface ConstructiveContext {
202
217
  <K extends keyof BuiltinModuleMap>(name: K): Promise<BuiltinModuleMap[K] | undefined>;
203
218
  (name: string): Promise<unknown>;
204
219
  };
220
+ /**
221
+ * Resolve a shared BillingClient bound to this request's entity.
222
+ *
223
+ * Returns a BillingClient with `checkQuota`, `recordUsage`, and
224
+ * `logInference` methods. Returns null if billing_module is not
225
+ * provisioned for this database or if no entity ID is available.
226
+ *
227
+ * Lazy: only resolves billing/inferenceLog loaders on first call.
228
+ * Cached: subsequent calls return the same client instance.
229
+ */
230
+ useBilling(): Promise<BillingClient | null>;
231
+ /**
232
+ * Resolve per-database LLM provider config.
233
+ *
234
+ * Returns the resolved LlmConfig from the llm_module table, or null
235
+ * if the module is not provisioned for this database. Callers should
236
+ * fall back to environment variables when null.
237
+ *
238
+ * Lazy: only resolves the llm loader on first call.
239
+ * Cached: subsequent calls return the same config instance.
240
+ */
241
+ useLlm(): Promise<LlmConfig | null>;
205
242
  }
206
243
  declare global {
207
244
  namespace Express {