@forinda/kickjs-multi-tenant 2.3.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,15 +15,18 @@ pnpm add @forinda/kickjs-multi-tenant
15
15
  ## Features
16
16
 
17
17
  - `TenantAdapter` — lifecycle adapter that resolves tenant from requests
18
- - `TENANT_CONTEXT` token for injecting tenant info via DI
19
- - Pluggable resolution strategies: header, subdomain, path, or custom
20
- - Scoped DI for per-tenant service instances
18
+ - `TENANT_CONTEXT` token for injecting tenant info via DI (request-scoped via AsyncLocalStorage)
19
+ - `getCurrentTenant()` functional helper for use outside DI
20
+ - Pluggable resolution strategies: header, subdomain, path, query, or custom
21
+ - Per-tenant database switching: database, schema, or discriminator modes
22
+ - `TENANT_DB` token for injecting per-tenant database connections
23
+ - Integration with `@forinda/kickjs-auth` for tenant-scoped RBAC
21
24
 
22
25
  ## Quick Example
23
26
 
24
27
  ```typescript
25
28
  import { TenantAdapter, TENANT_CONTEXT, type TenantInfo } from '@forinda/kickjs-multi-tenant'
26
- import { Inject, Service } from '@forinda/kickjs-core'
29
+ import { Inject, Service } from '@forinda/kickjs'
27
30
 
28
31
  bootstrap({
29
32
  modules,
package/dist/index.d.mts CHANGED
@@ -1,6 +1,56 @@
1
1
 
2
2
  import { AdapterContext, AdapterMiddleware, AppAdapter } from "@forinda/kickjs";
3
+ import { AsyncLocalStorage } from "node:async_hooks";
3
4
 
5
+ //#region src/database.d.ts
6
+ /**
7
+ * Per-tenant database switching configuration.
8
+ *
9
+ * Three isolation modes, from strongest to weakest:
10
+ * - `database` — each tenant has its own database
11
+ * - `schema` — shared database, separate schemas (PostgreSQL)
12
+ * - `discriminator` — shared tables with a tenant_id column
13
+ */
14
+ interface DatabaseConnectionInfo {
15
+ host: string;
16
+ port?: number;
17
+ database: string;
18
+ user: string;
19
+ password: string;
20
+ }
21
+ interface DatabasePerTenantConfig {
22
+ mode: 'database';
23
+ /** Resolve connection info for a tenant */
24
+ resolve: (tenantId: string) => DatabaseConnectionInfo | Promise<DatabaseConnectionInfo>;
25
+ /** Connection pool settings */
26
+ pool?: {
27
+ min?: number;
28
+ max?: number;
29
+ idleTimeout?: number;
30
+ };
31
+ /** Cache resolved connections (default TTL: 300_000ms = 5 min) */
32
+ cache?: {
33
+ ttl?: number;
34
+ };
35
+ }
36
+ interface SchemaPerTenantConfig {
37
+ mode: 'schema';
38
+ /** Base connection URL (shared database) */
39
+ connection: string;
40
+ /** Schema name template. `${tenantId}` is replaced at runtime. Default: `'tenant_${tenantId}'` */
41
+ schemaTemplate?: string;
42
+ }
43
+ interface DiscriminatorConfig {
44
+ mode: 'discriminator';
45
+ /** Base connection URL (shared everything) */
46
+ connection: string;
47
+ /** Column name used to scope queries. Default: `'tenant_id'` */
48
+ column?: string;
49
+ }
50
+ type TenantDatabase = DatabasePerTenantConfig | SchemaPerTenantConfig | DiscriminatorConfig;
51
+ /** DI token for the current tenant's database connection */
52
+ declare const TENANT_DB: unique symbol;
53
+ //#endregion
4
54
  //#region src/types.d.ts
5
55
  /** DI token for the current tenant context */
6
56
  declare const TENANT_CONTEXT: unique symbol;
@@ -38,6 +88,12 @@ interface MultiTenantOptions {
38
88
  required?: boolean;
39
89
  /** Routes to skip tenant resolution (e.g., health checks) */
40
90
  excludeRoutes?: string[];
91
+ /**
92
+ * Per-tenant database switching configuration.
93
+ * When set, a TENANT_DB DI token is registered that resolves to
94
+ * the current tenant's database connection.
95
+ */
96
+ database?: TenantDatabase;
41
97
  }
42
98
  //#endregion
43
99
  //#region src/tenant.adapter.d.ts
@@ -81,5 +137,24 @@ declare class TenantAdapter implements AppAdapter {
81
137
  private resolveTenant;
82
138
  }
83
139
  //#endregion
84
- export { type MultiTenantOptions, TENANT_CONTEXT, TenantAdapter, type TenantInfo, type TenantResolutionStrategy };
140
+ //#region src/tenant.context.d.ts
141
+ /**
142
+ * Get the current request's tenant from AsyncLocalStorage.
143
+ *
144
+ * Returns `undefined` when called outside a request scope (e.g.,
145
+ * during startup, in a background job, or in tests without setup).
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * import { getCurrentTenant } from '@forinda/kickjs-multi-tenant'
150
+ *
151
+ * function logForTenant(message: string) {
152
+ * const tenant = getCurrentTenant()
153
+ * console.log(`[${tenant?.id ?? 'no-tenant'}] ${message}`)
154
+ * }
155
+ * ```
156
+ */
157
+ declare function getCurrentTenant(): TenantInfo | undefined;
158
+ //#endregion
159
+ export { type DatabaseConnectionInfo, type DatabasePerTenantConfig, type DiscriminatorConfig, type MultiTenantOptions, type SchemaPerTenantConfig, TENANT_CONTEXT, TENANT_DB, TenantAdapter, type TenantDatabase, type TenantInfo, type TenantResolutionStrategy, getCurrentTenant };
85
160
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/tenant.adapter.ts"],"mappings":";;;;;cACa,cAAA;;UAGI,UAAA;EAHoC;EAKnD,EAAA;EALmD;EAOnD,IAAA;EAJe;EAMf,QAAA,GAAW,MAAA;AAAA;;KAID,wBAAA,iDAKN,GAAA,UAAa,UAAA,UAAoB,OAAA,CAAQ,UAAA;AAAA,UAE9B,kBAAA;EAXf;;;;AAIF;;;;EAgBE,QAAA,GAAW,wBAAA;EAX0B;EAcrC,UAAA;EAd4C;EAiB5C,UAAA;EAjBiB;;;;EAuBjB,gBAAA,IAAoB,MAAA,EAAQ,UAAA,EAAY,GAAA,iBAAoB,OAAA;EArB7C;EAwBf,QAAA;;EAGA,aAAA;AAAA;;;;AA/CF;;;;;AAGA;;;;;;;;;;AAUA;;;;;;;;;;;;;cCgCa,aAAA,YAAyB,UAAA;EACpC,IAAA;EAAA,QACQ,OAAA;cAKI,OAAA,GAAS,kBAAA;EAUrB,UAAA,CAAA,GAAc,iBAAA;EAoCd,WAAA,CAAA;IAAc;EAAA,GAAa,cAAA;EAAA,QAab,aAAA;AAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/database.ts","../src/types.ts","../src/tenant.adapter.ts","../src/tenant.context.ts"],"mappings":";;;;;;;;;AASA;;;;UAAiB,sBAAA;EACf,IAAA;EACA,IAAA;EACA,QAAA;EACA,IAAA;EACA,QAAA;AAAA;AAAA,UAGe,uBAAA;EACf,IAAA;;EAEA,OAAA,GAAU,QAAA,aAAqB,sBAAA,GAAyB,OAAA,CAAQ,sBAAA;EAAA;EAEhE,IAAA;IAAS,GAAA;IAAc,GAAA;IAAc,WAAA;EAAA;EAF3B;EAIV,KAAA;IAAU,GAAA;EAAA;AAAA;AAAA,UAGK,qBAAA;EACf,IAAA;EANqC;EAQrC,UAAA;EANU;EAQV,cAAA;AAAA;AAAA,UAGe,mBAAA;EACf,IAAA;;EAEA,UAAA;EAVA;EAYA,MAAA;AAAA;AAAA,KAGU,cAAA,GAAiB,uBAAA,GAA0B,qBAAA,GAAwB,mBAAA;;cAGlE,SAAA;;;;cC7CA,cAAA;;UAGI,UAAA;EDKA;ECHf,EAAA;;EAEA,IAAA;EDEA;ECAA,QAAA,GAAW,MAAA;AAAA;;KAID,wBAAA,iDAKN,GAAA,UAAa,UAAA,UAAoB,OAAA,CAAQ,UAAA;AAAA,UAE9B,kBAAA;EDPP;AAGV;;;;;;;ECaE,QAAA,GAAW,wBAAA;EDZX;ECeA,UAAA;EDbU;ECgBV,UAAA;EDhBwD;;;;ECsBxD,gBAAA,IAAoB,MAAA,EAAQ,UAAA,EAAY,GAAA,iBAAoB,OAAA;EDpBvB;ECuBrC,QAAA;EDrBU;ECwBV,aAAA;EDxBa;AAGf;;;;EC4BE,QAAA,GAbmE,cAAA;AAAA;;;;;ADjCrE;;;;;;;;;;;AAQA;;;;;;;;;;;;;;;;cE8Ba,aAAA,YAAyB,UAAA;EACpC,IAAA;EAAA,QACQ,OAAA;cAKI,OAAA,GAAS,kBAAA;EAUrB,UAAA,CAAA,GAAc,iBAAA;EAuCd,WAAA,CAAA;IAAc;EAAA,GAAa,cAAA;EAAA,QAqBb,aAAA;AAAA;;;;;;;;;;;AF3GhB;;;;;;;;iBGQgB,gBAAA,CAAA,GAAoB,UAAA"}
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @forinda/kickjs-multi-tenant v2.3.2
2
+ * @forinda/kickjs-multi-tenant v3.0.0
3
3
  *
4
4
  * Copyright (c) Felix Orinda
5
5
  *
@@ -9,10 +9,37 @@
9
9
  * @license MIT
10
10
  */
11
11
  import { Logger, Scope } from "@forinda/kickjs";
12
+ import { AsyncLocalStorage } from "node:async_hooks";
12
13
  //#region src/types.ts
13
14
  /** DI token for the current tenant context */
14
15
  const TENANT_CONTEXT = Symbol("TenantContext");
15
16
  //#endregion
17
+ //#region src/tenant.context.ts
18
+ /**
19
+ * AsyncLocalStorage instance that holds the current request's tenant.
20
+ * Used internally by TenantAdapter to make tenant resolution request-scoped.
21
+ */
22
+ const tenantStorage = new AsyncLocalStorage();
23
+ /**
24
+ * Get the current request's tenant from AsyncLocalStorage.
25
+ *
26
+ * Returns `undefined` when called outside a request scope (e.g.,
27
+ * during startup, in a background job, or in tests without setup).
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * import { getCurrentTenant } from '@forinda/kickjs-multi-tenant'
32
+ *
33
+ * function logForTenant(message: string) {
34
+ * const tenant = getCurrentTenant()
35
+ * console.log(`[${tenant?.id ?? 'no-tenant'}] ${message}`)
36
+ * }
37
+ * ```
38
+ */
39
+ function getCurrentTenant() {
40
+ return tenantStorage.getStore();
41
+ }
42
+ //#endregion
16
43
  //#region src/tenant.adapter.ts
17
44
  const log = Logger.for("MultiTenant");
18
45
  /**
@@ -70,16 +97,17 @@ var TenantAdapter = class {
70
97
  }
71
98
  req.tenant = tenant;
72
99
  if (this.options.onTenantResolved) await this.options.onTenantResolved(tenant, req);
73
- next();
100
+ tenantStorage.run(tenant, () => next());
74
101
  },
75
102
  phase: "beforeGlobal"
76
103
  }];
77
104
  }
78
105
  beforeStart({ container }) {
79
- container.registerFactory(TENANT_CONTEXT, () => ({
80
- id: "default",
81
- name: "Default Tenant"
82
- }), Scope.SINGLETON);
106
+ container.registerFactory(TENANT_CONTEXT, () => {
107
+ const tenant = getCurrentTenant();
108
+ if (!tenant) throw new Error("TENANT_CONTEXT resolved outside request scope. Ensure TenantAdapter middleware is active and the code runs within a request.");
109
+ return tenant;
110
+ }, Scope.TRANSIENT);
83
111
  log.info(`Tenant resolution: ${typeof this.options.strategy === "function" ? "custom" : this.options.strategy}`);
84
112
  }
85
113
  async resolveTenant(req) {
@@ -109,6 +137,10 @@ var TenantAdapter = class {
109
137
  }
110
138
  };
111
139
  //#endregion
112
- export { TENANT_CONTEXT, TenantAdapter };
140
+ //#region src/database.ts
141
+ /** DI token for the current tenant's database connection */
142
+ const TENANT_DB = Symbol("TenantDB");
143
+ //#endregion
144
+ export { TENANT_CONTEXT, TENANT_DB, TenantAdapter, getCurrentTenant };
113
145
 
114
146
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/tenant.adapter.ts"],"sourcesContent":["/** DI token for the current tenant context */\nexport const TENANT_CONTEXT = Symbol('TenantContext')\n\n/** Tenant information resolved from the request */\nexport interface TenantInfo {\n /** Unique tenant identifier */\n id: string\n /** Optional tenant name */\n name?: string\n /** Optional tenant-specific config/metadata */\n metadata?: Record<string, any>\n}\n\n/** Strategy for resolving the tenant from a request */\nexport type TenantResolutionStrategy =\n | 'header'\n | 'subdomain'\n | 'path'\n | 'query'\n | ((req: any) => TenantInfo | null | Promise<TenantInfo | null>)\n\nexport interface MultiTenantOptions {\n /**\n * How to resolve the tenant from the request.\n * - 'header' — reads X-Tenant-ID header (default)\n * - 'subdomain' — extracts from subdomain (tenant.example.com)\n * - 'path' — extracts from first path segment (/tenant-id/...)\n * - 'query' — reads ?tenantId= query param\n * - function — custom resolver\n */\n strategy?: TenantResolutionStrategy\n\n /** Header name when strategy is 'header' (default: 'x-tenant-id') */\n headerName?: string\n\n /** Query param name when strategy is 'query' (default: 'tenantId') */\n queryParam?: string\n\n /**\n * Called after tenant is resolved. Use for validation, loading tenant\n * config from DB, or rejecting unknown tenants.\n */\n onTenantResolved?: (tenant: TenantInfo, req: any) => void | Promise<void>\n\n /** Return a 403 if no tenant can be resolved (default: true) */\n required?: boolean\n\n /** Routes to skip tenant resolution (e.g., health checks) */\n excludeRoutes?: string[]\n}\n","import {\n Logger,\n type AppAdapter,\n type AdapterContext,\n type AdapterMiddleware,\n Scope,\n} from '@forinda/kickjs'\nimport type { Request, Response, NextFunction } from 'express'\nimport {\n TENANT_CONTEXT,\n type TenantInfo,\n type MultiTenantOptions,\n type TenantResolutionStrategy,\n} from './types'\n\nconst log = Logger.for('MultiTenant')\n\n/**\n * Multi-tenancy adapter for KickJS.\n *\n * Resolves the tenant from each request and makes it available\n * via DI (`@Inject(TENANT_CONTEXT)`) and `req.tenant`.\n *\n * @example\n * ```ts\n * import { TenantAdapter, TENANT_CONTEXT } from '@forinda/kickjs-multi-tenant'\n *\n * bootstrap({\n * modules,\n * adapters: [\n * new TenantAdapter({\n * strategy: 'header',\n * onTenantResolved: async (tenant) => {\n * // Load tenant config from DB, validate, etc.\n * },\n * }),\n * ],\n * })\n *\n * // In a service:\n * @Service()\n * class UserService {\n * constructor(@Inject(TENANT_CONTEXT) private tenant: TenantInfo) {}\n * }\n * ```\n */\nexport class TenantAdapter implements AppAdapter {\n name = 'TenantAdapter'\n private options: Required<\n Pick<MultiTenantOptions, 'strategy' | 'required' | 'headerName' | 'queryParam'>\n > &\n MultiTenantOptions\n\n constructor(options: MultiTenantOptions = {}) {\n this.options = {\n strategy: options.strategy ?? 'header',\n required: options.required ?? true,\n headerName: options.headerName ?? 'x-tenant-id',\n queryParam: options.queryParam ?? 'tenantId',\n ...options,\n }\n }\n\n middleware(): AdapterMiddleware[] {\n return [\n {\n handler: async (req: Request, res: Response, next: NextFunction) => {\n // Skip excluded routes\n if (this.options.excludeRoutes?.some((r) => req.path.startsWith(r))) {\n return next()\n }\n\n const tenant = await this.resolveTenant(req)\n\n if (!tenant) {\n if (this.options.required) {\n res\n .status(403)\n .json({ message: 'Tenant not found. Provide a valid tenant identifier.' })\n return\n }\n return next()\n }\n\n // Attach to request\n ;(req as any).tenant = tenant\n\n // Call hook\n if (this.options.onTenantResolved) {\n await this.options.onTenantResolved(tenant, req)\n }\n\n next()\n },\n phase: 'beforeGlobal',\n },\n ]\n }\n\n beforeStart({ container }: AdapterContext): void {\n // Register a factory that reads the tenant from the current request context\n // This requires request-scoped resolution — for now, register as a placeholder\n container.registerFactory(\n TENANT_CONTEXT,\n () => ({ id: 'default', name: 'Default Tenant' }) as TenantInfo,\n Scope.SINGLETON,\n )\n log.info(\n `Tenant resolution: ${typeof this.options.strategy === 'function' ? 'custom' : this.options.strategy}`,\n )\n }\n\n private async resolveTenant(req: Request): Promise<TenantInfo | null> {\n const strategy = this.options.strategy\n\n if (typeof strategy === 'function') {\n return strategy(req)\n }\n\n switch (strategy) {\n case 'header': {\n const tenantId = req.get(this.options.headerName)\n return tenantId ? { id: tenantId } : null\n }\n case 'subdomain': {\n const host = req.hostname\n const parts = host.split('.')\n if (parts.length >= 3) {\n return { id: parts[0] }\n }\n return null\n }\n case 'path': {\n const segments = req.path.split('/').filter(Boolean)\n if (segments.length > 0) {\n return { id: segments[0] }\n }\n return null\n }\n case 'query': {\n const tenantId = req.query[this.options.queryParam] as string\n return tenantId ? { id: tenantId } : null\n }\n default:\n return null\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;AACA,MAAa,iBAAiB,OAAO,gBAAgB;;;ACcrD,MAAM,MAAM,OAAO,IAAI,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BrC,IAAa,gBAAb,MAAiD;CAC/C,OAAO;CACP;CAKA,YAAY,UAA8B,EAAE,EAAE;AAC5C,OAAK,UAAU;GACb,UAAU,QAAQ,YAAY;GAC9B,UAAU,QAAQ,YAAY;GAC9B,YAAY,QAAQ,cAAc;GAClC,YAAY,QAAQ,cAAc;GAClC,GAAG;GACJ;;CAGH,aAAkC;AAChC,SAAO,CACL;GACE,SAAS,OAAO,KAAc,KAAe,SAAuB;AAElE,QAAI,KAAK,QAAQ,eAAe,MAAM,MAAM,IAAI,KAAK,WAAW,EAAE,CAAC,CACjE,QAAO,MAAM;IAGf,MAAM,SAAS,MAAM,KAAK,cAAc,IAAI;AAE5C,QAAI,CAAC,QAAQ;AACX,SAAI,KAAK,QAAQ,UAAU;AACzB,UACG,OAAO,IAAI,CACX,KAAK,EAAE,SAAS,wDAAwD,CAAC;AAC5E;;AAEF,YAAO,MAAM;;AAIb,QAAY,SAAS;AAGvB,QAAI,KAAK,QAAQ,iBACf,OAAM,KAAK,QAAQ,iBAAiB,QAAQ,IAAI;AAGlD,UAAM;;GAER,OAAO;GACR,CACF;;CAGH,YAAY,EAAE,aAAmC;AAG/C,YAAU,gBACR,uBACO;GAAE,IAAI;GAAW,MAAM;GAAkB,GAChD,MAAM,UACP;AACD,MAAI,KACF,sBAAsB,OAAO,KAAK,QAAQ,aAAa,aAAa,WAAW,KAAK,QAAQ,WAC7F;;CAGH,MAAc,cAAc,KAA0C;EACpE,MAAM,WAAW,KAAK,QAAQ;AAE9B,MAAI,OAAO,aAAa,WACtB,QAAO,SAAS,IAAI;AAGtB,UAAQ,UAAR;GACE,KAAK,UAAU;IACb,MAAM,WAAW,IAAI,IAAI,KAAK,QAAQ,WAAW;AACjD,WAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;GAEvC,KAAK,aAAa;IAEhB,MAAM,QADO,IAAI,SACE,MAAM,IAAI;AAC7B,QAAI,MAAM,UAAU,EAClB,QAAO,EAAE,IAAI,MAAM,IAAI;AAEzB,WAAO;;GAET,KAAK,QAAQ;IACX,MAAM,WAAW,IAAI,KAAK,MAAM,IAAI,CAAC,OAAO,QAAQ;AACpD,QAAI,SAAS,SAAS,EACpB,QAAO,EAAE,IAAI,SAAS,IAAI;AAE5B,WAAO;;GAET,KAAK,SAAS;IACZ,MAAM,WAAW,IAAI,MAAM,KAAK,QAAQ;AACxC,WAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;GAEvC,QACE,QAAO"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/tenant.context.ts","../src/tenant.adapter.ts","../src/database.ts"],"sourcesContent":["/** DI token for the current tenant context */\nexport const TENANT_CONTEXT = Symbol('TenantContext')\n\n/** Tenant information resolved from the request */\nexport interface TenantInfo {\n /** Unique tenant identifier */\n id: string\n /** Optional tenant name */\n name?: string\n /** Optional tenant-specific config/metadata */\n metadata?: Record<string, any>\n}\n\n/** Strategy for resolving the tenant from a request */\nexport type TenantResolutionStrategy =\n | 'header'\n | 'subdomain'\n | 'path'\n | 'query'\n | ((req: any) => TenantInfo | null | Promise<TenantInfo | null>)\n\nexport interface MultiTenantOptions {\n /**\n * How to resolve the tenant from the request.\n * - 'header' — reads X-Tenant-ID header (default)\n * - 'subdomain' — extracts from subdomain (tenant.example.com)\n * - 'path' — extracts from first path segment (/tenant-id/...)\n * - 'query' — reads ?tenantId= query param\n * - function — custom resolver\n */\n strategy?: TenantResolutionStrategy\n\n /** Header name when strategy is 'header' (default: 'x-tenant-id') */\n headerName?: string\n\n /** Query param name when strategy is 'query' (default: 'tenantId') */\n queryParam?: string\n\n /**\n * Called after tenant is resolved. Use for validation, loading tenant\n * config from DB, or rejecting unknown tenants.\n */\n onTenantResolved?: (tenant: TenantInfo, req: any) => void | Promise<void>\n\n /** Return a 403 if no tenant can be resolved (default: true) */\n required?: boolean\n\n /** Routes to skip tenant resolution (e.g., health checks) */\n excludeRoutes?: string[]\n\n /**\n * Per-tenant database switching configuration.\n * When set, a TENANT_DB DI token is registered that resolves to\n * the current tenant's database connection.\n */\n database?: import('./database').TenantDatabase\n}\n","import { AsyncLocalStorage } from 'node:async_hooks'\nimport type { TenantInfo } from './types'\n\n/**\n * AsyncLocalStorage instance that holds the current request's tenant.\n * Used internally by TenantAdapter to make tenant resolution request-scoped.\n */\nexport const tenantStorage = new AsyncLocalStorage<TenantInfo>()\n\n/**\n * Get the current request's tenant from AsyncLocalStorage.\n *\n * Returns `undefined` when called outside a request scope (e.g.,\n * during startup, in a background job, or in tests without setup).\n *\n * @example\n * ```ts\n * import { getCurrentTenant } from '@forinda/kickjs-multi-tenant'\n *\n * function logForTenant(message: string) {\n * const tenant = getCurrentTenant()\n * console.log(`[${tenant?.id ?? 'no-tenant'}] ${message}`)\n * }\n * ```\n */\nexport function getCurrentTenant(): TenantInfo | undefined {\n return tenantStorage.getStore()\n}\n","import {\n Logger,\n type AppAdapter,\n type AdapterContext,\n type AdapterMiddleware,\n Scope,\n} from '@forinda/kickjs'\nimport type { Request, Response, NextFunction } from 'express'\nimport {\n TENANT_CONTEXT,\n type TenantInfo,\n type MultiTenantOptions,\n type TenantResolutionStrategy,\n} from './types'\nimport { tenantStorage, getCurrentTenant } from './tenant.context'\n\nconst log = Logger.for('MultiTenant')\n\n/**\n * Multi-tenancy adapter for KickJS.\n *\n * Resolves the tenant from each request and makes it available\n * via DI (`@Inject(TENANT_CONTEXT)`) and `req.tenant`.\n *\n * @example\n * ```ts\n * import { TenantAdapter, TENANT_CONTEXT } from '@forinda/kickjs-multi-tenant'\n *\n * bootstrap({\n * modules,\n * adapters: [\n * new TenantAdapter({\n * strategy: 'header',\n * onTenantResolved: async (tenant) => {\n * // Load tenant config from DB, validate, etc.\n * },\n * }),\n * ],\n * })\n *\n * // In a service:\n * @Service()\n * class UserService {\n * constructor(@Inject(TENANT_CONTEXT) private tenant: TenantInfo) {}\n * }\n * ```\n */\nexport class TenantAdapter implements AppAdapter {\n name = 'TenantAdapter'\n private options: Required<\n Pick<MultiTenantOptions, 'strategy' | 'required' | 'headerName' | 'queryParam'>\n > &\n MultiTenantOptions\n\n constructor(options: MultiTenantOptions = {}) {\n this.options = {\n strategy: options.strategy ?? 'header',\n required: options.required ?? true,\n headerName: options.headerName ?? 'x-tenant-id',\n queryParam: options.queryParam ?? 'tenantId',\n ...options,\n }\n }\n\n middleware(): AdapterMiddleware[] {\n return [\n {\n handler: async (req: Request, res: Response, next: NextFunction) => {\n // Skip excluded routes\n if (this.options.excludeRoutes?.some((r) => req.path.startsWith(r))) {\n return next()\n }\n\n const tenant = await this.resolveTenant(req)\n\n if (!tenant) {\n if (this.options.required) {\n res\n .status(403)\n .json({ message: 'Tenant not found. Provide a valid tenant identifier.' })\n return\n }\n return next()\n }\n\n // Attach to request\n ;(req as any).tenant = tenant\n\n // Call hook\n if (this.options.onTenantResolved) {\n await this.options.onTenantResolved(tenant, req)\n }\n\n // Wrap the rest of the request in AsyncLocalStorage so\n // @Inject(TENANT_CONTEXT) and getCurrentTenant() return\n // the correct tenant for this request.\n tenantStorage.run(tenant, () => next())\n },\n phase: 'beforeGlobal',\n },\n ]\n }\n\n beforeStart({ container }: AdapterContext): void {\n // Register as TRANSIENT so each resolution reads from AsyncLocalStorage\n container.registerFactory(\n TENANT_CONTEXT,\n () => {\n const tenant = getCurrentTenant()\n if (!tenant) {\n throw new Error(\n 'TENANT_CONTEXT resolved outside request scope. ' +\n 'Ensure TenantAdapter middleware is active and the code runs within a request.',\n )\n }\n return tenant\n },\n Scope.TRANSIENT,\n )\n log.info(\n `Tenant resolution: ${typeof this.options.strategy === 'function' ? 'custom' : this.options.strategy}`,\n )\n }\n\n private async resolveTenant(req: Request): Promise<TenantInfo | null> {\n const strategy = this.options.strategy\n\n if (typeof strategy === 'function') {\n return strategy(req)\n }\n\n switch (strategy) {\n case 'header': {\n const tenantId = req.get(this.options.headerName)\n return tenantId ? { id: tenantId } : null\n }\n case 'subdomain': {\n const host = req.hostname\n const parts = host.split('.')\n if (parts.length >= 3) {\n return { id: parts[0] }\n }\n return null\n }\n case 'path': {\n const segments = req.path.split('/').filter(Boolean)\n if (segments.length > 0) {\n return { id: segments[0] }\n }\n return null\n }\n case 'query': {\n const tenantId = req.query[this.options.queryParam] as string\n return tenantId ? { id: tenantId } : null\n }\n default:\n return null\n }\n }\n}\n","/**\n * Per-tenant database switching configuration.\n *\n * Three isolation modes, from strongest to weakest:\n * - `database` — each tenant has its own database\n * - `schema` — shared database, separate schemas (PostgreSQL)\n * - `discriminator` — shared tables with a tenant_id column\n */\n\nexport interface DatabaseConnectionInfo {\n host: string\n port?: number\n database: string\n user: string\n password: string\n}\n\nexport interface DatabasePerTenantConfig {\n mode: 'database'\n /** Resolve connection info for a tenant */\n resolve: (tenantId: string) => DatabaseConnectionInfo | Promise<DatabaseConnectionInfo>\n /** Connection pool settings */\n pool?: { min?: number; max?: number; idleTimeout?: number }\n /** Cache resolved connections (default TTL: 300_000ms = 5 min) */\n cache?: { ttl?: number }\n}\n\nexport interface SchemaPerTenantConfig {\n mode: 'schema'\n /** Base connection URL (shared database) */\n connection: string\n /** Schema name template. `${tenantId}` is replaced at runtime. Default: `'tenant_${tenantId}'` */\n schemaTemplate?: string\n}\n\nexport interface DiscriminatorConfig {\n mode: 'discriminator'\n /** Base connection URL (shared everything) */\n connection: string\n /** Column name used to scope queries. Default: `'tenant_id'` */\n column?: string\n}\n\nexport type TenantDatabase = DatabasePerTenantConfig | SchemaPerTenantConfig | DiscriminatorConfig\n\n/** DI token for the current tenant's database connection */\nexport const TENANT_DB = Symbol('TenantDB')\n"],"mappings":";;;;;;;;;;;;;;AACA,MAAa,iBAAiB,OAAO,gBAAgB;;;;;;;ACMrD,MAAa,gBAAgB,IAAI,mBAA+B;;;;;;;;;;;;;;;;;AAkBhE,SAAgB,mBAA2C;AACzD,QAAO,cAAc,UAAU;;;;ACVjC,MAAM,MAAM,OAAO,IAAI,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BrC,IAAa,gBAAb,MAAiD;CAC/C,OAAO;CACP;CAKA,YAAY,UAA8B,EAAE,EAAE;AAC5C,OAAK,UAAU;GACb,UAAU,QAAQ,YAAY;GAC9B,UAAU,QAAQ,YAAY;GAC9B,YAAY,QAAQ,cAAc;GAClC,YAAY,QAAQ,cAAc;GAClC,GAAG;GACJ;;CAGH,aAAkC;AAChC,SAAO,CACL;GACE,SAAS,OAAO,KAAc,KAAe,SAAuB;AAElE,QAAI,KAAK,QAAQ,eAAe,MAAM,MAAM,IAAI,KAAK,WAAW,EAAE,CAAC,CACjE,QAAO,MAAM;IAGf,MAAM,SAAS,MAAM,KAAK,cAAc,IAAI;AAE5C,QAAI,CAAC,QAAQ;AACX,SAAI,KAAK,QAAQ,UAAU;AACzB,UACG,OAAO,IAAI,CACX,KAAK,EAAE,SAAS,wDAAwD,CAAC;AAC5E;;AAEF,YAAO,MAAM;;AAIb,QAAY,SAAS;AAGvB,QAAI,KAAK,QAAQ,iBACf,OAAM,KAAK,QAAQ,iBAAiB,QAAQ,IAAI;AAMlD,kBAAc,IAAI,cAAc,MAAM,CAAC;;GAEzC,OAAO;GACR,CACF;;CAGH,YAAY,EAAE,aAAmC;AAE/C,YAAU,gBACR,sBACM;GACJ,MAAM,SAAS,kBAAkB;AACjC,OAAI,CAAC,OACH,OAAM,IAAI,MACR,+HAED;AAEH,UAAO;KAET,MAAM,UACP;AACD,MAAI,KACF,sBAAsB,OAAO,KAAK,QAAQ,aAAa,aAAa,WAAW,KAAK,QAAQ,WAC7F;;CAGH,MAAc,cAAc,KAA0C;EACpE,MAAM,WAAW,KAAK,QAAQ;AAE9B,MAAI,OAAO,aAAa,WACtB,QAAO,SAAS,IAAI;AAGtB,UAAQ,UAAR;GACE,KAAK,UAAU;IACb,MAAM,WAAW,IAAI,IAAI,KAAK,QAAQ,WAAW;AACjD,WAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;GAEvC,KAAK,aAAa;IAEhB,MAAM,QADO,IAAI,SACE,MAAM,IAAI;AAC7B,QAAI,MAAM,UAAU,EAClB,QAAO,EAAE,IAAI,MAAM,IAAI;AAEzB,WAAO;;GAET,KAAK,QAAQ;IACX,MAAM,WAAW,IAAI,KAAK,MAAM,IAAI,CAAC,OAAO,QAAQ;AACpD,QAAI,SAAS,SAAS,EACpB,QAAO,EAAE,IAAI,SAAS,IAAI;AAE5B,WAAO;;GAET,KAAK,SAAS;IACZ,MAAM,WAAW,IAAI,MAAM,KAAK,QAAQ;AACxC,WAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;GAEvC,QACE,QAAO;;;;;;;AC9Gf,MAAa,YAAY,OAAO,WAAW"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forinda/kickjs-multi-tenant",
3
- "version": "2.3.2",
3
+ "version": "3.0.0",
4
4
  "description": "Multi-tenancy helpers for KickJS — tenant resolution, scoped DI, and database routing",
5
5
  "keywords": [
6
6
  "kickjs",
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "reflect-metadata": "^0.2.2",
73
- "@forinda/kickjs": "2.3.2"
73
+ "@forinda/kickjs": "3.0.0"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@types/node": "^25.0.0",