@forinda/kickjs-multi-tenant 4.0.0 → 4.2.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 +5 -0
- package/dist/index.d.mts +15 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +24 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# @forinda/kickjs-multi-tenant
|
|
2
2
|
|
|
3
|
+
> [!WARNING] Deprecated — going private in v4.1.2.
|
|
4
|
+
> This package is being retired. The replacement is a short BYO recipe using `defineAdapter` / `definePlugin` from `@forinda/kickjs` directly — see **[guide/multi-tenancy](https://forinda.github.io/kick-js/guide/multi-tenancy)** for the copy-paste alternative.
|
|
5
|
+
>
|
|
6
|
+
> The package still works in v4.1.x; v4.1.2 will remove it from the public registry. Migrate at your convenience.
|
|
7
|
+
|
|
3
8
|
Multi-tenancy for KickJS — tenant resolution from header/subdomain/path/query/custom, request-scoped DI via AsyncLocalStorage, and per-tenant DB routing through the `prisma` / `drizzle` tenant adapters.
|
|
4
9
|
|
|
5
10
|
## Install
|
package/dist/index.d.mts
CHANGED
|
@@ -48,8 +48,21 @@ interface DiscriminatorConfig {
|
|
|
48
48
|
column?: string;
|
|
49
49
|
}
|
|
50
50
|
type TenantDatabase = DatabasePerTenantConfig | SchemaPerTenantConfig | DiscriminatorConfig;
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* DI token for the current tenant's database connection.
|
|
53
|
+
*
|
|
54
|
+
* Typed as `unknown` because the concrete database type depends on the
|
|
55
|
+
* adopter's ORM (Drizzle, Prisma, raw client, …); cast at the use site:
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* @Service()
|
|
60
|
+
* class UserRepo {
|
|
61
|
+
* constructor(@Inject(TENANT_DB) private db: NodePgDatabase<typeof schema>) {}
|
|
62
|
+
* }
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
declare const TENANT_DB: _$_forinda_kickjs0.InjectionToken<unknown>;
|
|
53
66
|
//#endregion
|
|
54
67
|
//#region src/types.d.ts
|
|
55
68
|
/** Tenant information resolved from the request */
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/database.ts","../src/types.ts","../src/tenant.adapter.ts","../src/tenant.context.ts"],"mappings":";;;;;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/database.ts","../src/types.ts","../src/tenant.adapter.ts","../src/tenant.context.ts"],"mappings":";;;;;;;;;AAWA;;;;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;;AAR/E;;;;;;;;;AAQA;;;;cAgBa,SAAA,EAAwD,kBAAA,CAA/C,cAAA;;;;UC1DL,UAAA;;EAEf,EAAA;EDMe;ECJf,IAAA;;EAEA,QAAA,GAAW,MAAA;AAAA;;;;;;;ADUb;cCAa,cAAA,EAAc,kBAAA,CAAA,cAAA,CAAA,UAAA;;KAGf,wBAAA,iDAKN,GAAA,UAAa,UAAA,UAAoB,OAAA,CAAQ,UAAA;AAAA,UAE9B,kBAAA;EDPyC;;;;;;;;ECgBxD,QAAA,GAAW,wBAAA;EDdX;ECiBA,UAAA;EDjBuB;ECoBvB,UAAA;EDlBA;;;;ECwBA,gBAAA,IAAoB,MAAA,EAAQ,UAAA,EAAY,GAAA,iBAAoB,OAAA;EDrBxB;ECwBpC,QAAA;EDxBoC;EC2BpC,aAAA;EDxBA;;;;AAKF;EC0BE,QAAA,GAbmE,cAAA;AAAA;;;;;;ADvCrE;;;;;;;;;;;AAQA;;;;;;;;;;;;;;;;;;;;;;AAUA;;;;cEmBa,aAAA,EAAa,kBAAA,CAAA,cAAA,CAAA,kBAAA;;;;;;;;;;;AF7B1B;;;;;;;;iBGMgB,gBAAA,CAAA,GAAoB,UAAA"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-multi-tenant v4.
|
|
2
|
+
* @forinda/kickjs-multi-tenant v4.2.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -181,8 +181,29 @@ async function resolveTenant(req, options) {
|
|
|
181
181
|
}
|
|
182
182
|
//#endregion
|
|
183
183
|
//#region src/database.ts
|
|
184
|
-
/**
|
|
185
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Per-tenant database switching configuration.
|
|
186
|
+
*
|
|
187
|
+
* Three isolation modes, from strongest to weakest:
|
|
188
|
+
* - `database` — each tenant has its own database
|
|
189
|
+
* - `schema` — shared database, separate schemas (PostgreSQL)
|
|
190
|
+
* - `discriminator` — shared tables with a tenant_id column
|
|
191
|
+
*/
|
|
192
|
+
/**
|
|
193
|
+
* DI token for the current tenant's database connection.
|
|
194
|
+
*
|
|
195
|
+
* Typed as `unknown` because the concrete database type depends on the
|
|
196
|
+
* adopter's ORM (Drizzle, Prisma, raw client, …); cast at the use site:
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```ts
|
|
200
|
+
* @Service()
|
|
201
|
+
* class UserRepo {
|
|
202
|
+
* constructor(@Inject(TENANT_DB) private db: NodePgDatabase<typeof schema>) {}
|
|
203
|
+
* }
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
const TENANT_DB = createToken("kick/multi-tenant/db");
|
|
186
207
|
//#endregion
|
|
187
208
|
export { TENANT_CONTEXT, TENANT_DB, TenantAdapter, getCurrentTenant };
|
|
188
209
|
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/tenant.context.ts","../src/tenant.adapter.ts","../src/database.ts"],"sourcesContent":["import { createToken } from '@forinda/kickjs'\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/**\n * DI token for the current tenant context.\n *\n * Backed by AsyncLocalStorage — registered as `Scope.TRANSIENT` so each\n * resolution returns the request-scoped tenant. Throws if resolved\n * outside a request that ran the tenant middleware.\n */\nexport const TENANT_CONTEXT = createToken<TenantInfo>('kick/tenant/Context')\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 { Logger, Scope, defineAdapter, type AdapterMiddleware } from '@forinda/kickjs'\nimport { PROTOCOL_VERSION, type IntrospectionSnapshot } from '@forinda/kickjs-devtools-kit'\nimport type { Request, Response, NextFunction } from 'express'\nimport { TENANT_CONTEXT, type TenantInfo, type MultiTenantOptions } 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 via DI\n * (`@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 * 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 *\n * Multiple shards or independent tenant pipelines? Use `.scoped()` for\n * a per-shard instance — each one gets its own `name` (e.g.\n * `TenantAdapter:eu`) so `dependsOn` lookups stay unambiguous:\n *\n * ```ts\n * adapters: [\n * TenantAdapter.scoped('eu', { strategy: 'header', headerName: 'x-eu-tenant' }),\n * TenantAdapter.scoped('us', { strategy: 'header', headerName: 'x-us-tenant' }),\n * ]\n * ```\n */\nexport const TenantAdapter = defineAdapter<MultiTenantOptions>({\n name: 'TenantAdapter',\n defaults: {\n strategy: 'header',\n required: true,\n headerName: 'x-tenant-id',\n queryParam: 'tenantId',\n },\n build: (options) => {\n // Tracked across the request middleware so introspect() can report\n // a coarse \"tenants resolved since boot\" counter to DevTools without\n // any per-request overhead beyond the increment itself.\n let tenantsResolved = 0\n let tenantsRejected = 0\n\n return {\n // ── DevTools introspection (architecture.md §23) ───────────────\n introspect(): IntrospectionSnapshot {\n return {\n protocolVersion: PROTOCOL_VERSION,\n name: 'TenantAdapter',\n kind: 'adapter',\n state: {\n strategy:\n typeof options.strategy === 'function' ? 'custom' : (options.strategy ?? null),\n required: options.required ?? true,\n headerName: options.headerName ?? null,\n queryParam: options.queryParam ?? null,\n },\n tokens: { provides: ['kick/tenant/Context'], requires: [] },\n metrics: {\n tenantsResolved,\n tenantsRejected,\n },\n }\n },\n\n middleware(): AdapterMiddleware[] {\n return [\n {\n handler: async (req: Request, res: Response, next: NextFunction) => {\n if (options.excludeRoutes?.some((r) => req.path.startsWith(r))) {\n return next()\n }\n\n const tenant = await resolveTenant(req, options)\n\n if (!tenant) {\n if (options.required) {\n tenantsRejected++\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 tenantsResolved++\n ;(req as unknown as { tenant: TenantInfo }).tenant = tenant\n\n if (options.onTenantResolved) {\n await options.onTenantResolved(tenant, req)\n }\n\n // Wrap the rest of the request in AsyncLocalStorage so\n // @Inject(TENANT_CONTEXT) and getCurrentTenant() return the\n // correct tenant for this request.\n tenantStorage.run(tenant, () => next())\n },\n phase: 'beforeGlobal',\n },\n ]\n },\n\n beforeStart({ container }) {\n // 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 options.strategy === 'function' ? 'custom' : options.strategy}`,\n )\n },\n }\n },\n})\n\nasync function resolveTenant(\n req: Request,\n options: MultiTenantOptions,\n): Promise<TenantInfo | null> {\n const strategy = 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(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[options.queryParam!] as string\n return tenantId ? { id: tenantId } : null\n }\n default:\n return null\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":";;;;;;;;;;;;;;;;;;;;;AAmBA,MAAa,iBAAiB,YAAwB,sBAAsB;;;;;;;ACZ5E,MAAa,gBAAgB,IAAI,mBAA+B;;;;;;;;;;;;;;;;;AAkBhE,SAAgB,mBAA2C;AACzD,QAAO,cAAc,UAAU;;;;ACpBjC,MAAM,MAAM,OAAO,IAAI,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CrC,MAAa,gBAAgB,cAAkC;CAC7D,MAAM;CACN,UAAU;EACR,UAAU;EACV,UAAU;EACV,YAAY;EACZ,YAAY;EACb;CACD,QAAQ,YAAY;EAIlB,IAAI,kBAAkB;EACtB,IAAI,kBAAkB;AAEtB,SAAO;GAEL,aAAoC;AAClC,WAAO;KACL,iBAAiB;KACjB,MAAM;KACN,MAAM;KACN,OAAO;MACL,UACE,OAAO,QAAQ,aAAa,aAAa,WAAY,QAAQ,YAAY;MAC3E,UAAU,QAAQ,YAAY;MAC9B,YAAY,QAAQ,cAAc;MAClC,YAAY,QAAQ,cAAc;MACnC;KACD,QAAQ;MAAE,UAAU,CAAC,sBAAsB;MAAE,UAAU,EAAE;MAAE;KAC3D,SAAS;MACP;MACA;MACD;KACF;;GAGH,aAAkC;AAChC,WAAO,CACL;KACE,SAAS,OAAO,KAAc,KAAe,SAAuB;AAClE,UAAI,QAAQ,eAAe,MAAM,MAAM,IAAI,KAAK,WAAW,EAAE,CAAC,CAC5D,QAAO,MAAM;MAGf,MAAM,SAAS,MAAM,cAAc,KAAK,QAAQ;AAEhD,UAAI,CAAC,QAAQ;AACX,WAAI,QAAQ,UAAU;AACpB;AACA,YACG,OAAO,IAAI,CACX,KAAK,EAAE,SAAS,wDAAwD,CAAC;AAC5E;;AAEF,cAAO,MAAM;;AAGf;AACE,UAA0C,SAAS;AAErD,UAAI,QAAQ,iBACV,OAAM,QAAQ,iBAAiB,QAAQ,IAAI;AAM7C,oBAAc,IAAI,cAAc,MAAM,CAAC;;KAEzC,OAAO;KACR,CACF;;GAGH,YAAY,EAAE,aAAa;AAEzB,cAAU,gBACR,sBACM;KACJ,MAAM,SAAS,kBAAkB;AACjC,SAAI,CAAC,OACH,OAAM,IAAI,MACR,+HAED;AAEH,YAAO;OAET,MAAM,UACP;AACD,QAAI,KACF,sBAAsB,OAAO,QAAQ,aAAa,aAAa,WAAW,QAAQ,WACnF;;GAEJ;;CAEJ,CAAC;AAEF,eAAe,cACb,KACA,SAC4B;CAC5B,MAAM,WAAW,QAAQ;AAEzB,KAAI,OAAO,aAAa,WACtB,QAAO,SAAS,IAAI;AAGtB,SAAQ,UAAR;EACE,KAAK,UAAU;GACb,MAAM,WAAW,IAAI,IAAI,QAAQ,WAAY;AAC7C,UAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;EAEvC,KAAK,aAAa;GAEhB,MAAM,QADO,IAAI,SACE,MAAM,IAAI;AAC7B,OAAI,MAAM,UAAU,EAClB,QAAO,EAAE,IAAI,MAAM,IAAI;AAEzB,UAAO;;EAET,KAAK,QAAQ;GACX,MAAM,WAAW,IAAI,KAAK,MAAM,IAAI,CAAC,OAAO,QAAQ;AACpD,OAAI,SAAS,SAAS,EACpB,QAAO,EAAE,IAAI,SAAS,IAAI;AAE5B,UAAO;;EAET,KAAK,SAAS;GACZ,MAAM,WAAW,IAAI,MAAM,QAAQ;AACnC,UAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;EAEvC,QACE,QAAO;;;;;;ACxIb,MAAa,YAAY,OAAO,WAAW"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/tenant.context.ts","../src/tenant.adapter.ts","../src/database.ts"],"sourcesContent":["import { createToken } from '@forinda/kickjs'\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/**\n * DI token for the current tenant context.\n *\n * Backed by AsyncLocalStorage — registered as `Scope.TRANSIENT` so each\n * resolution returns the request-scoped tenant. Throws if resolved\n * outside a request that ran the tenant middleware.\n */\nexport const TENANT_CONTEXT = createToken<TenantInfo>('kick/tenant/Context')\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 { Logger, Scope, defineAdapter, type AdapterMiddleware } from '@forinda/kickjs'\nimport { PROTOCOL_VERSION, type IntrospectionSnapshot } from '@forinda/kickjs-devtools-kit'\nimport type { Request, Response, NextFunction } from 'express'\nimport { TENANT_CONTEXT, type TenantInfo, type MultiTenantOptions } 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 via DI\n * (`@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 * 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 *\n * Multiple shards or independent tenant pipelines? Use `.scoped()` for\n * a per-shard instance — each one gets its own `name` (e.g.\n * `TenantAdapter:eu`) so `dependsOn` lookups stay unambiguous:\n *\n * ```ts\n * adapters: [\n * TenantAdapter.scoped('eu', { strategy: 'header', headerName: 'x-eu-tenant' }),\n * TenantAdapter.scoped('us', { strategy: 'header', headerName: 'x-us-tenant' }),\n * ]\n * ```\n */\nexport const TenantAdapter = defineAdapter<MultiTenantOptions>({\n name: 'TenantAdapter',\n defaults: {\n strategy: 'header',\n required: true,\n headerName: 'x-tenant-id',\n queryParam: 'tenantId',\n },\n build: (options) => {\n // Tracked across the request middleware so introspect() can report\n // a coarse \"tenants resolved since boot\" counter to DevTools without\n // any per-request overhead beyond the increment itself.\n let tenantsResolved = 0\n let tenantsRejected = 0\n\n return {\n // ── DevTools introspection (architecture.md §23) ───────────────\n introspect(): IntrospectionSnapshot {\n return {\n protocolVersion: PROTOCOL_VERSION,\n name: 'TenantAdapter',\n kind: 'adapter',\n state: {\n strategy:\n typeof options.strategy === 'function' ? 'custom' : (options.strategy ?? null),\n required: options.required ?? true,\n headerName: options.headerName ?? null,\n queryParam: options.queryParam ?? null,\n },\n tokens: { provides: ['kick/tenant/Context'], requires: [] },\n metrics: {\n tenantsResolved,\n tenantsRejected,\n },\n }\n },\n\n middleware(): AdapterMiddleware[] {\n return [\n {\n handler: async (req: Request, res: Response, next: NextFunction) => {\n if (options.excludeRoutes?.some((r) => req.path.startsWith(r))) {\n return next()\n }\n\n const tenant = await resolveTenant(req, options)\n\n if (!tenant) {\n if (options.required) {\n tenantsRejected++\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 tenantsResolved++\n ;(req as unknown as { tenant: TenantInfo }).tenant = tenant\n\n if (options.onTenantResolved) {\n await options.onTenantResolved(tenant, req)\n }\n\n // Wrap the rest of the request in AsyncLocalStorage so\n // @Inject(TENANT_CONTEXT) and getCurrentTenant() return the\n // correct tenant for this request.\n tenantStorage.run(tenant, () => next())\n },\n phase: 'beforeGlobal',\n },\n ]\n },\n\n beforeStart({ container }) {\n // 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 options.strategy === 'function' ? 'custom' : options.strategy}`,\n )\n },\n }\n },\n})\n\nasync function resolveTenant(\n req: Request,\n options: MultiTenantOptions,\n): Promise<TenantInfo | null> {\n const strategy = 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(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[options.queryParam!] as string\n return tenantId ? { id: tenantId } : null\n }\n default:\n return null\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\nimport { createToken } from '@forinda/kickjs'\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/**\n * DI token for the current tenant's database connection.\n *\n * Typed as `unknown` because the concrete database type depends on the\n * adopter's ORM (Drizzle, Prisma, raw client, …); cast at the use site:\n *\n * @example\n * ```ts\n * @Service()\n * class UserRepo {\n * constructor(@Inject(TENANT_DB) private db: NodePgDatabase<typeof schema>) {}\n * }\n * ```\n */\nexport const TENANT_DB = createToken<unknown>('kick/multi-tenant/db')\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAmBA,MAAa,iBAAiB,YAAwB,sBAAsB;;;;;;;ACZ5E,MAAa,gBAAgB,IAAI,mBAA+B;;;;;;;;;;;;;;;;;AAkBhE,SAAgB,mBAA2C;AACzD,QAAO,cAAc,UAAU;;;;ACpBjC,MAAM,MAAM,OAAO,IAAI,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CrC,MAAa,gBAAgB,cAAkC;CAC7D,MAAM;CACN,UAAU;EACR,UAAU;EACV,UAAU;EACV,YAAY;EACZ,YAAY;EACb;CACD,QAAQ,YAAY;EAIlB,IAAI,kBAAkB;EACtB,IAAI,kBAAkB;AAEtB,SAAO;GAEL,aAAoC;AAClC,WAAO;KACL,iBAAiB;KACjB,MAAM;KACN,MAAM;KACN,OAAO;MACL,UACE,OAAO,QAAQ,aAAa,aAAa,WAAY,QAAQ,YAAY;MAC3E,UAAU,QAAQ,YAAY;MAC9B,YAAY,QAAQ,cAAc;MAClC,YAAY,QAAQ,cAAc;MACnC;KACD,QAAQ;MAAE,UAAU,CAAC,sBAAsB;MAAE,UAAU,EAAE;MAAE;KAC3D,SAAS;MACP;MACA;MACD;KACF;;GAGH,aAAkC;AAChC,WAAO,CACL;KACE,SAAS,OAAO,KAAc,KAAe,SAAuB;AAClE,UAAI,QAAQ,eAAe,MAAM,MAAM,IAAI,KAAK,WAAW,EAAE,CAAC,CAC5D,QAAO,MAAM;MAGf,MAAM,SAAS,MAAM,cAAc,KAAK,QAAQ;AAEhD,UAAI,CAAC,QAAQ;AACX,WAAI,QAAQ,UAAU;AACpB;AACA,YACG,OAAO,IAAI,CACX,KAAK,EAAE,SAAS,wDAAwD,CAAC;AAC5E;;AAEF,cAAO,MAAM;;AAGf;AACE,UAA0C,SAAS;AAErD,UAAI,QAAQ,iBACV,OAAM,QAAQ,iBAAiB,QAAQ,IAAI;AAM7C,oBAAc,IAAI,cAAc,MAAM,CAAC;;KAEzC,OAAO;KACR,CACF;;GAGH,YAAY,EAAE,aAAa;AAEzB,cAAU,gBACR,sBACM;KACJ,MAAM,SAAS,kBAAkB;AACjC,SAAI,CAAC,OACH,OAAM,IAAI,MACR,+HAED;AAEH,YAAO;OAET,MAAM,UACP;AACD,QAAI,KACF,sBAAsB,OAAO,QAAQ,aAAa,aAAa,WAAW,QAAQ,WACnF;;GAEJ;;CAEJ,CAAC;AAEF,eAAe,cACb,KACA,SAC4B;CAC5B,MAAM,WAAW,QAAQ;AAEzB,KAAI,OAAO,aAAa,WACtB,QAAO,SAAS,IAAI;AAGtB,SAAQ,UAAR;EACE,KAAK,UAAU;GACb,MAAM,WAAW,IAAI,IAAI,QAAQ,WAAY;AAC7C,UAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;EAEvC,KAAK,aAAa;GAEhB,MAAM,QADO,IAAI,SACE,MAAM,IAAI;AAC7B,OAAI,MAAM,UAAU,EAClB,QAAO,EAAE,IAAI,MAAM,IAAI;AAEzB,UAAO;;EAET,KAAK,QAAQ;GACX,MAAM,WAAW,IAAI,KAAK,MAAM,IAAI,CAAC,OAAO,QAAQ;AACpD,OAAI,SAAS,SAAS,EACpB,QAAO,EAAE,IAAI,SAAS,IAAI;AAE5B,UAAO;;EAET,KAAK,SAAS;GACZ,MAAM,WAAW,IAAI,MAAM,QAAQ;AACnC,UAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;EAEvC,QACE,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;ACzHb,MAAa,YAAY,YAAqB,uBAAuB"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forinda/kickjs-multi-tenant",
|
|
3
|
-
"version": "4.
|
|
4
|
-
"description": "Multi-tenancy helpers for KickJS — tenant resolution, scoped DI, and database routing",
|
|
3
|
+
"version": "4.2.0",
|
|
4
|
+
"description": "[DEPRECATED] Multi-tenancy helpers for KickJS — tenant resolution, scoped DI, and database routing — see https://forinda.github.io/kick-js/guide/multi-tenancy for the BYO replacement (going private in v4.1.2).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kickjs",
|
|
7
7
|
"nodejs",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"reflect-metadata": "^0.2.2",
|
|
68
|
-
"@forinda/kickjs-devtools-kit": "4.
|
|
68
|
+
"@forinda/kickjs-devtools-kit": "4.2.0"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@types/express": "^5.0.6",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"typescript": "^6.0.3",
|
|
75
75
|
"unplugin-swc": "^1.5.7",
|
|
76
76
|
"vitest": "^4.1.5",
|
|
77
|
-
"@forinda/kickjs": "4.
|
|
77
|
+
"@forinda/kickjs": "4.2.0"
|
|
78
78
|
},
|
|
79
79
|
"publishConfig": {
|
|
80
80
|
"access": "public"
|
|
@@ -97,6 +97,7 @@
|
|
|
97
97
|
"@forinda/kickjs": ">=2.3.0"
|
|
98
98
|
},
|
|
99
99
|
"peerDependenciesMeta": {},
|
|
100
|
+
"deprecated": "Deprecated — going private in v4.1.2. Replaced by a BYO recipe using defineAdapter/definePlugin from @forinda/kickjs directly. See https://forinda.github.io/kick-js/guide/multi-tenancy for the migration.",
|
|
100
101
|
"scripts": {
|
|
101
102
|
"build": "wireit",
|
|
102
103
|
"dev": "tsdown --watch",
|