@elysiumjs/mnemosyne 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/dist/cache.d.ts +8 -4
- package/dist/database.d.ts +64 -3
- package/dist/index.js +1 -1
- package/dist/interfaces.d.ts +16 -6
- package/dist/keyv-redis.d.ts +1 -1
- package/dist/repository.d.ts +4 -4
- package/dist/tenancy.d.ts +25 -3
- package/package.json +16 -6
- package/.turbo/turbo-build.log +0 -8
- package/src/cache.ts +0 -219
- package/src/database.ts +0 -114
- package/src/index.ts +0 -37
- package/src/interfaces.ts +0 -449
- package/src/keyv-redis.ts +0 -326
- package/src/mnemosyne.module.ts +0 -83
- package/src/model.ts +0 -189
- package/src/repository.ts +0 -137
- package/src/tenancy.ts +0 -274
- package/tests/cache.test.ts +0 -505
- package/tests/database.test.ts +0 -113
- package/tests/mnemosyne.module.test.ts +0 -214
- package/tests/model.test.ts +0 -234
- package/tests/repository.test.ts +0 -177
- package/tests/setup.ts +0 -13
- package/tsconfig.json +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# @elysiumjs/mnemosyne
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [[`68a0cdd`](https://github.com/na2axl/elysiumjs/commit/68a0cdd8b2e4a003d6e0af2d78985623cae40428), [`2c49acd`](https://github.com/na2axl/elysiumjs/commit/2c49acd3b552aa43765d70339fa216961499e1b5), [`76ba0ff`](https://github.com/na2axl/elysiumjs/commit/76ba0ff18f3c196cebc2189426328f67cb699f46), [`4ebf029`](https://github.com/na2axl/elysiumjs/commit/4ebf029611e6d753a4f1bfdbe247a75d954d9ded), [`bacce71`](https://github.com/na2axl/elysiumjs/commit/bacce7146f667a1a46bf474dc0344fb0111ae12a)]:
|
|
8
|
+
- @elysiumjs/core@0.10.0
|
|
9
|
+
|
|
10
|
+
## 0.3.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- [`5fef4a7`](https://github.com/na2axl/elysiumjs/commit/5fef4a7199f502fc4fe035299e7670739459a108) Thanks [@na2axl](https://github.com/na2axl)! - Fix tenant isolation, transactions and query caching in the database layer.
|
|
15
|
+
|
|
16
|
+
Breaking changes:
|
|
17
|
+
|
|
18
|
+
- `TenantMiddleware.validateTenant()` now rejects every tenant unless you override it, and tenant identifiers must match `TENANT_ID_PATTERN` (1 to 63 ASCII letters, digits, `_` or `-`, not starting with `pg_` and other than `information_schema`, in any case) before it is called. Requests that send any other tenant now fail with `Invalid tenant`. Requests without a tenant still resolve to `public` on `SimpleTenantMiddleware`. To migrate, check the tenant against your own data:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
class OrganizationMiddleware extends SimpleTenantMiddleware {
|
|
22
|
+
protected async validateTenant(tenant: string): Promise<boolean> {
|
|
23
|
+
return Organization.exists(tenant);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- `getTenantSchema()`, `registerTenantSchema()` and `wrapTenantSchema()` throw on tenant identifiers that do not match `TENANT_ID_PATTERN`.
|
|
29
|
+
- `setConnectionTenant()` is removed. It set the tenant on a pooled connection for the whole session, so the tenant leaked to other requests. Use `withRLS()`, or `Database.transaction()` in RLS mode, which scope the tenant to a transaction.
|
|
30
|
+
- `RepositoryInterface.update()` and `delete()`, and the `DrizzleRepository` implementations, return `null` when no row has the id. They returned `undefined` while typed as a row. `DrizzleRepository` also requires a model whose table has an `id` column.
|
|
31
|
+
- `@transactional` handlers run through the `db.transaction` service that `DrizzleModule` provides (its name is exported by `@elysiumjs/core` as `DATABASE_TRANSACTION_SERVICE`, and still re-exported by `@elysiumjs/mnemosyne`). A transactional handler without a database module now fails with an error naming the service.
|
|
32
|
+
- `@transactional` generator handlers commit only when the whole stream was sent. A stream closed early, or whose client disconnects (even while the handler is waiting), rolls back.
|
|
33
|
+
- In RLS mode, a transaction is bound to one tenant for its whole life: the tenant set (with a transaction-local `set_config`) when `Database.transaction()`, a `@transactional` handler, `withRLS()` or `withIsolation()` opens it, from the explicit tenant or the current one (`public` when there is none). Work that finds a transaction open on its connection (`withRLS()`, `withIsolation()`, `Database.transaction()`, repository calls) joins it only when its tenant is the transaction's tenant, and throws `TenantMismatchError` otherwise, without running anything: the tenant of an open transaction never changes. Inside a transaction, `Database.getConnection()` only returns it to work for its tenant (the current tenant, or `public`), and throws `TenantMismatchError` otherwise, so raw queries never run for another tenant; queries on the `tx` handle a callback received run as the transaction's tenant. Work for several tenants, such as an admin fan-out, must use one `withRLS()` per tenant outside any transaction, not nested in one.
|
|
34
|
+
- In RLS mode, each repository call made outside a transaction runs in its own transaction bound to the current tenant: `BEGIN`, `set_config` of the tenant, the query, `COMMIT`. These calls do not use the query cache, since the cache is bypassed inside transactions. Group calls in `Database.transaction()` or `withRLS()` to share one transaction.
|
|
35
|
+
- In RLS mode, code running without a tenant in context (background jobs, CLI commands, scripts) is scoped to the `public` tenant. Set the tenant with `withTenant(tenant, () => ...)` around the work (repository calls and `Database.transaction()` then use it), or with `withRLS(tenant, (tx) => ...)`.
|
|
36
|
+
- `migration:run` validates `--tenant` against `TENANT_ID_PATTERN`. Without a terminal it no longer creates a missing schema: pass `--create-schema` (which also skips the question in a terminal), or it fails.
|
|
37
|
+
- Query cache keys start with the connection name, and each connection has its own cache transaction scope. When several applications or environments share one cache store and use the same connection names for different databases, set `cache: { prefix }` on each connection: the prefix comes before the connection name in every key (empty by default).
|
|
38
|
+
- `DrizzleDatabaseCache` keys and values changed: entries written by earlier versions are ignored. The default TTL of cached queries (including with `cache: true`) is now 60 seconds instead of 1 second, and is set with `cache: { ttl }` in seconds.
|
|
39
|
+
- `Cache.memory` keeps a separate LRU per tag namespace, so `lruSize` applies to each namespace.
|
|
40
|
+
|
|
41
|
+
Fixes and additions:
|
|
42
|
+
|
|
43
|
+
- In RLS mode, repository operations outside a transaction run in one bound to the current tenant, through the strategy's `withIsolation()`. Transactions opened with `Database.transaction()` or by `@transactional` handlers set the tenant variable when they open. `withIsolation()` and `withRLS()` expose their transaction to repositories and `Database.getConnection()`. `TenantMismatchError` is exported.
|
|
44
|
+
- `Database.transaction(callback, connection?)` opens a transaction and registers it for the callback. When a transaction is already open on the same connection, the callback joins it (no savepoint: an error rolls back the whole transaction); in RLS mode only if the current tenant is the transaction's tenant (see above). Transactions are tracked by connection, so every name of a connection (`default` and the name it aliases) sees them, and named connections take part in transactions. `Database.getBaseConnection()`, `Database.inTransaction()` and `Database.runInTransaction()` are added. The `db:tx` context entry is still set for the default connection.
|
|
45
|
+
- `@transactional` generator handlers keep their transaction open until the stream ends, then commit (or roll back if the handler throws). They previously committed before running and failed with `generator.next is not a function`. Generator handlers also see the request context (`tenant`, `http:context`).
|
|
46
|
+
- The query cache is scoped to the connection and the current tenant, is shared correctly by several app instances (table versions stored in the cache), is not read or written inside transactions, and invalidates the tables a transaction changed once it commits. A result is only stored when no mutation happened since its query started, including when concurrent queries of the same key finish out of order. Cached queries honour Drizzle's `ex`, `px`, `exat`, `pxat` and `keepTtl` options.
|
|
47
|
+
- Cache namespaces (`Cache.redis.tags(...)`, `Cache.memory.tags(...)`) no longer overwrite each other: reads, deletes and `clear()` stay within their namespace.
|
|
48
|
+
- `getSessionTenant()` reads the Bun SQL result correctly, and `getCurrentTenant()` works without an application instance (for example under `Application.testing`).
|
|
49
|
+
- `migration:run` takes an advisory lock per tenant, adds a unique index on `elysium_migrations.name` (also to existing tables), finds the migrations from the project root (`.elysiumrc`), sets the `search_path` for the transaction only, asks before opening the transaction whether to create a missing schema, and always closes its client. Existing duplicate migration names must be removed before the unique index can be created.
|
|
50
|
+
- `migration:generate` only removes `"public".` qualifiers from the migration it generates, and leaves string literals, comments and dollar-quoted bodies (`DO` blocks, functions) untouched.
|
|
51
|
+
- `DatabaseDriver.createConnection()` receives the connection name as an optional second argument.
|
|
52
|
+
- `KeyvRedis` deletes with a single `UNLINK` (or `DEL`) per call or batch and reports failed deletes in `clear()` as `error` events.
|
|
53
|
+
|
|
54
|
+
### Patch Changes
|
|
55
|
+
|
|
56
|
+
- Updated dependencies [[`5fef4a7`](https://github.com/na2axl/elysiumjs/commit/5fef4a7199f502fc4fe035299e7670739459a108)]:
|
|
57
|
+
- @elysiumjs/core@0.9.0
|
|
58
|
+
|
|
3
59
|
## 0.2.0
|
|
4
60
|
|
|
5
61
|
### Minor Changes
|
package/dist/cache.d.ts
CHANGED
|
@@ -82,12 +82,16 @@ export interface CacheInterface {
|
|
|
82
82
|
tags(...tags: string[]): Omit<CacheInterface, 'tags'>;
|
|
83
83
|
}
|
|
84
84
|
/**
|
|
85
|
-
* Makes a cache interface
|
|
86
|
-
*
|
|
87
|
-
*
|
|
85
|
+
* Makes a cache interface over stores built by the given factory.
|
|
86
|
+
*
|
|
87
|
+
* Keyv writes its namespace on the store adapter it wraps, so adapters cannot be shared between
|
|
88
|
+
* namespaces: the last namespace would win for every Keyv using the adapter, and `clear()` would
|
|
89
|
+
* wipe the other namespaces. The base cache and each tagged cache therefore get their own adapter.
|
|
90
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
91
|
+
* @param createStore Creates a new store adapter, called once per namespace.
|
|
88
92
|
* @returns A new `CacheInterface`.
|
|
89
93
|
*/
|
|
90
|
-
export declare const makeCacheInterface: (
|
|
94
|
+
export declare const makeCacheInterface: (createStore: () => KeyvStoreAdapter) => CacheInterface;
|
|
91
95
|
/**
|
|
92
96
|
* Static access to the caches provided by the `MnemosyneModule`.
|
|
93
97
|
* @author Axel Nana <axel.nana@workbud.com>
|
package/dist/database.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { CacheInterface } from './cache';
|
|
2
2
|
import type { DatabaseCacheConfig, DatabaseDriver } from './interfaces';
|
|
3
|
+
/**
|
|
4
|
+
* Name of the global service running a callback in a database transaction, registered by driver
|
|
5
|
+
* modules. Used by `@transactional` HTTP handlers; defined by `@elysiumjs/core`.
|
|
6
|
+
*/
|
|
7
|
+
export { DATABASE_TRANSACTION_SERVICE } from '@elysiumjs/core';
|
|
3
8
|
/**
|
|
4
9
|
* Creates a cache storage instance tagged for database use.
|
|
5
10
|
* Drivers can use this to build their cache backends.
|
|
@@ -34,15 +39,71 @@ export declare abstract class AbstractDatabase<TConnection, TConnectionConfig> {
|
|
|
34
39
|
/**
|
|
35
40
|
* Retrieves the connection with the given name.
|
|
36
41
|
*
|
|
37
|
-
*
|
|
38
|
-
* transaction handle
|
|
42
|
+
* When a transaction is open on that connection in the current async context (see
|
|
43
|
+
* {@link transaction}), the transaction handle is returned instead. Transactions are tracked by
|
|
44
|
+
* the connection they run on, so every name of a connection (such as `default` and the name it
|
|
45
|
+
* aliases) sees them.
|
|
39
46
|
* @throws {ServiceNotFoundError} If no connection with that name is registered.
|
|
40
47
|
*
|
|
41
48
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
42
49
|
* @param name The connection name.
|
|
43
|
-
* @returns The connection with the given name.
|
|
50
|
+
* @returns The connection with the given name, or its open transaction.
|
|
44
51
|
*/
|
|
45
52
|
getConnection(name?: string): TConnection;
|
|
53
|
+
/**
|
|
54
|
+
* Retrieves the connection with the given name, ignoring any open transaction.
|
|
55
|
+
* @throws {ServiceNotFoundError} If no connection with that name is registered.
|
|
56
|
+
*
|
|
57
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
58
|
+
* @param name The connection name.
|
|
59
|
+
* @returns The connection registered under the given name.
|
|
60
|
+
*/
|
|
61
|
+
getBaseConnection(name?: string): TConnection;
|
|
62
|
+
/**
|
|
63
|
+
* Checks whether a transaction is open on the given connection in the current async context.
|
|
64
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
65
|
+
* @param name The connection name.
|
|
66
|
+
* @returns `true` if a transaction is open on the connection, `false` otherwise.
|
|
67
|
+
*/
|
|
68
|
+
inTransaction(name?: string): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Runs a callback in a transaction on the given connection.
|
|
71
|
+
*
|
|
72
|
+
* The transaction is registered in the async context for the duration of the callback, so that
|
|
73
|
+
* {@link getConnection} (and the repositories using it) returns it for that connection. When a
|
|
74
|
+
* transaction is already open on the connection, the callback joins it.
|
|
75
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
76
|
+
* @template T The return type of the callback.
|
|
77
|
+
* @param callback The function to run in the transaction. Receives the transaction handle.
|
|
78
|
+
* @param name The connection name.
|
|
79
|
+
* @returns The return value of the callback.
|
|
80
|
+
*/
|
|
81
|
+
transaction<T>(callback: (tx: TConnection) => Promise<T>, name?: string): Promise<T>;
|
|
82
|
+
/**
|
|
83
|
+
* Runs a callback with a transaction registered in the async context for a connection.
|
|
84
|
+
*
|
|
85
|
+
* The current context store is copied, never modified. Drivers and tenancy strategies that open
|
|
86
|
+
* transactions themselves use it to make the transaction visible to {@link getConnection}.
|
|
87
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
88
|
+
* @template T The return type of the callback.
|
|
89
|
+
* @param connection The connection the transaction runs on.
|
|
90
|
+
* @param tx The transaction handle.
|
|
91
|
+
* @param callback The function to run.
|
|
92
|
+
* @returns The return value of the callback.
|
|
93
|
+
*/
|
|
94
|
+
runInTransaction<T>(connection: TConnection, tx: TConnection, callback: () => T): T;
|
|
95
|
+
/**
|
|
96
|
+
* Gets the transaction open on a connection in the current async context.
|
|
97
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
98
|
+
* @param connection The connection, as returned by {@link getBaseConnection}.
|
|
99
|
+
* @returns The transaction handle, or `undefined` if none is open.
|
|
100
|
+
*/
|
|
101
|
+
getActiveTransaction(connection: TConnection): TConnection | undefined;
|
|
102
|
+
/**
|
|
103
|
+
* Checks whether a connection is the one the `default` name resolves to.
|
|
104
|
+
* @param connection The connection.
|
|
105
|
+
*/
|
|
106
|
+
private isDefaultConnection;
|
|
46
107
|
/**
|
|
47
108
|
* Checks if a connection with the given name exists.
|
|
48
109
|
* @author Axel Nana <axel.nana@workbud.com>
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var __legacyDecorateClassTS=function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};import{Application,Service as Service2,ServiceNotFoundError as ServiceNotFoundError2}from"@elysiumjs/core";import{Service,ServiceNotFoundError}from"@elysiumjs/core";import{createCache}from"cache-manager";import{Keyv}from"keyv";import{alphabetical}from"radash";var REDIS_CACHE_SERVICE="mnemosyne.cache.redis",MEMORY_CACHE_SERVICE="mnemosyne.cache.memory",makeCacheInterface=(store)=>{let base=createCache({stores:[new Keyv({store,namespace:"cache"})]}),tagsCache=new Map;return{...base,tags(...tags){let namespace=`cache__${alphabetical(tags,(item)=>item).join("__")}`,cache=tagsCache.get(namespace);if(!cache)cache=createCache({stores:[new Keyv({store,namespace})]}),tagsCache.set(namespace,cache);return cache}}},getCache=(name)=>{let cache=Service.get(name);if(!cache)throw new ServiceNotFoundError(name);return cache};class Cache{static weak=Object.seal({map:new WeakMap,get(ctx){if(!this.map.has(ctx))this.map.set(ctx,{});return this.map.get(ctx)},del(ctx){this.map.delete(ctx)}});static get redis(){return getCache(REDIS_CACHE_SERVICE)}static get memory(){return getCache(MEMORY_CACHE_SERVICE)}}var createDatabaseCacheStorage=(config)=>Cache[config.storage??"redis"].tags("database");class AbstractDatabase{static getConnectionName(name){return`db.connection.${name}`}getConnection(name="default"){if(name==="default"){let tx=Application.context.getStore()?.get("db:tx");if(tx)return tx}let serviceName=AbstractDatabase.getConnectionName(name),connection=Service2.get(serviceName);if(connection===null)throw new ServiceNotFoundError2(serviceName);return connection}connectionExists(name){return Service2.exists(AbstractDatabase.getConnectionName(name))}getDefaultConnection(){return this.getConnection("default")}getRawClient(name="default"){return this.driver.getRawClient(this.getConnection(name))}}import{Application as Application3}from"@elysiumjs/core";import{t}from"elysia";import{Application as Application2,Middleware,Service as Service3}from"@elysiumjs/core";var TENANCY_CONFIG_SERVICE="mnemosyne.tenancy.config",TENANCY_STRATEGY_SERVICE="mnemosyne.tenancy.strategy",getTenancyConfig=()=>Service3.get(TENANCY_CONFIG_SERVICE)??{mode:"schema"},getTenancyStrategy=()=>Service3.get(TENANCY_STRATEGY_SERVICE),getCurrentTenant=()=>{if(!Application2.instance||!Application2.context.getStore)return null;return Application2.context.getStore()?.get("tenant")??null},withTenant=(tenant,callback)=>{let currentStore=Application2.context.getStore(),newStore=new Map(currentStore);return newStore.set("tenant",tenant),Application2.context.run(newStore,callback)};class TenantMiddleware extends Middleware{header="x-tenant-id";allowPublic=!0;connectionName="default";async validateTenant(_tenant){return!0}async onTenantResolved(_tenant,_ctx){}onTenantError(reason,_ctx){if(reason==="missing")throw Error("Tenant ID required");else throw Error("Invalid tenant")}async onRequest(ctx){let tenantId=ctx.request.headers.get(this.header);if(!tenantId||tenantId==="public"){if(this.allowPublic){ctx.tenant="public";return}return this.onTenantError("missing",ctx)}if(!await this.validateTenant(tenantId))return this.onTenantError("invalid",ctx);ctx.tenant=tenantId,await this.onTenantResolved(tenantId,ctx)}}class SimpleTenantMiddleware extends TenantMiddleware{}class StrictTenantMiddleware extends TenantMiddleware{allowPublic=!1}var createSchemaFromModel=(columns,{mode="select"}={})=>{if(columns===void 0||columns.length===0)return t.Object({});let properties={};for(let column of columns){if(["create","update"].includes(mode)&&column.isPrimaryKey)continue;if(properties[column.name]=parseColumnType(column),column.nullable)properties[column.name]=t.Nullable(properties[column.name]);if(mode==="update"||column.hasDefault||column.nullable)properties[column.name]=t.Optional(properties[column.name])}return t.Object(properties)},parseColumnType=(column)=>{switch(column.dataType){case"string":return t.String();case"uuid":return t.String({format:"uuid"});case"number":return t.Number();case"boolean":return t.Boolean();case"array":return t.Array(t.Any());case"json":return t.Object({});case"date":return t.Transform(t.String({format:"date"})).Decode((date)=>new Date(date)).Encode((date)=>date.toISOString());case"datetime":return t.Transform(t.String({format:"date-time"})).Decode((date)=>new Date(date)).Encode((date)=>date.toISOString());case"bigint":return t.BigInt();case"buffer":return t.File();default:return t.Never()}},AbstractModel=(tableName,columns,adapter,baseTable)=>{let columnMetadata=adapter.getColumns(baseTable);class M{static $inferSelect=void 0;static $inferInsert=void 0;static $inferUpdate=void 0;static get table(){if(!Application3.instance||!Application3.context.getStore)return baseTable;if(this.supportTenancy){let tenant=getCurrentTenant()??"public";if(tenant!=="public"){let strategy=getTenancyStrategy();if(strategy)return strategy.resolveTable(baseTable,tenant);return adapter.createTenantTable(baseTable,tenant)}}return baseTable}static tableName=tableName;static columns=columns;static insertSchema=createSchemaFromModel(columnMetadata,{mode:"create"});static updateSchema=createSchemaFromModel(columnMetadata,{mode:"update"});static selectSchema=createSchemaFromModel(columnMetadata,{mode:"select"});static supportTenancy=!1}return M};var AbstractRepository=(model,database)=>{class R{static Model=model;static connection="default";get db(){return database.getConnection(this.constructor.connection)}}return R};import{Module}from"@elysiumjs/core";import{KeyvCacheableMemory}from"cacheable";import{EventEmitter}from"events";import{Event,Redis,Service as Service4}from"@elysiumjs/core";class KeyvRedis extends EventEmitter{#client;#namespace;#keyPrefixSeparator="::";#clearBatchSize=1000;#useUnlink=!0;#noNamespaceAffectsAll=!1;#connection;constructor(props){super();this.#connection=props.connection,this.#namespace=props.namespace,this.#keyPrefixSeparator=props.keyPrefixSeparator??"::",this.#clearBatchSize=props.clearBatchSize??1000,this.#useUnlink=props.useUnlink??!0,this.#noNamespaceAffectsAll=props.noNamespaceAffectsAll??!1,this.on("error",(error)=>{Event.emit("elysium:error",error)})}async getClient(){return this.#client??=await Service4.resolve(Redis.getConnectionName(this.#connection)),this.#client}get opts(){return{namespace:this.#namespace,keyPrefixSeparator:this.#keyPrefixSeparator,clearBatchSize:this.#clearBatchSize,noNamespaceAffectsAll:this.#noNamespaceAffectsAll,dialect:"redis",url:this.#connection}}get namespace(){return this.#namespace}set namespace(value){this.#namespace=value}async get(key){let client=await this.getClient();key=this.createKeyPrefix(key,this.#namespace);let value=await client.get(key);return value===null?void 0:value}async set(key,value,ttl){let client=await this.getClient();if(key=this.createKeyPrefix(key,this.#namespace),ttl)await client.set(key,value,"PX",ttl);else await client.set(key,value)}async setMany(values){let client=await this.getClient(),multi=[];for(let{key,value,ttl}of values){let prefixedKey=this.createKeyPrefix(key,this.#namespace);if(ttl)multi.push(client.set(prefixedKey,value,"PX",ttl));else multi.push(client.set(prefixedKey,value))}await Promise.all(multi)}async delete(key){let client=await this.getClient();return key=this.createKeyPrefix(key,this.#namespace),await(this.#useUnlink?client.send("UNLINK",[key]):client.del(key))>0}async clear(){try{let client=await this.getClient();if(!this.#namespace&&this.#noNamespaceAffectsAll){await client.send("FLUSHDB",[]);return}let cursor="0",batchSize=this.#clearBatchSize,match=this.#namespace?`${this.#namespace}${this.#keyPrefixSeparator}*`:"*",deletePromises=[];do{let result=await client.send("SCAN",[cursor,"MATCH",match,"COUNT",batchSize.toString(),"TYPE","string"]),[newCursor,keys]=result;if(cursor=newCursor.toString(),keys.length===0)continue;if(!this.#namespace)keys=keys.filter((key)=>!key.includes(this.#keyPrefixSeparator));deletePromises.push(keys.map((key)=>client.del(key)))}while(cursor!=="0");await Promise.all(deletePromises)}catch(error){this.emit("error",error)}}async has(key){let client=await this.getClient();return key=this.createKeyPrefix(key,this.#namespace),client.exists(key)}async hasMany(keys){let client=await this.getClient(),multi=[];for(let key of keys){let prefixedKey=this.createKeyPrefix(key,this.#namespace);multi.push(client.exists(prefixedKey))}return Promise.all(multi)}async getMany(keys){let client=await this.getClient();if(keys.length===0)return[];return keys=keys.map((key)=>this.createKeyPrefix(key,this.#namespace)),await client.mget(...keys)}async disconnect(){this.#client=void 0}async deleteMany(keys){let client=await this.getClient(),result=!1,multi=[];for(let key of keys){let prefixedKey=this.createKeyPrefix(key,this.#namespace);if(this.#useUnlink)multi.push(client.send("UNLINK",[prefixedKey]));else multi.push(client.del(prefixedKey))}let results=await Promise.all(multi);for(let deleted of results)if(typeof deleted==="number"&&deleted>0)result=!0;return result}async*iterator(namespace){let client=await this.getClient(),match=namespace?`${namespace}${this.#keyPrefixSeparator}*`:"*",cursor="0";do{let result=await client.send("SCAN",[cursor,"MATCH",match,"TYPE","string"]),[newCursor,keys]=result;if(cursor=newCursor.toString(),!namespace&&!this.#noNamespaceAffectsAll)keys=keys.filter((key)=>!key.includes(this.#keyPrefixSeparator));if(keys.length>0){let values=await client.mget(...keys);for(let i of keys.keys()){let key=this.getKeyWithoutPrefix(keys[i],namespace),value=values[i];yield[key,value]}}}while(cursor!=="0")}createKeyPrefix(key,namespace){return namespace?`${namespace}${this.#keyPrefixSeparator}${key}`:key}getKeyWithoutPrefix(key,namespace){return namespace?key.replace(`${namespace}${this.#keyPrefixSeparator}`,""):key}}class MnemosyneModule extends Module.configurable({defaults:{cache:{redis:{connection:"cache"},memory:{ttl:60000,lruSize:5000}}}}){}MnemosyneModule=__legacyDecorateClassTS([Module.register({services:(config)=>{let services=[],{redis,memory}=config.cache;if(redis)services.push({name:REDIS_CACHE_SERVICE,global:!0,factory:()=>makeCacheInterface(new KeyvRedis({connection:redis.connection}))});if(memory)services.push({name:MEMORY_CACHE_SERVICE,global:!0,factory:()=>makeCacheInterface(new KeyvCacheableMemory({ttl:memory.ttl,lruSize:memory.lruSize}))});return services}})],MnemosyneModule);export{AbstractDatabase,AbstractModel,AbstractRepository,Cache,KeyvRedis,MEMORY_CACHE_SERVICE,MnemosyneModule,REDIS_CACHE_SERVICE,SimpleTenantMiddleware,StrictTenantMiddleware,TENANCY_CONFIG_SERVICE,TENANCY_STRATEGY_SERVICE,TenantMiddleware,createDatabaseCacheStorage,createSchemaFromModel,getCurrentTenant,getTenancyConfig,getTenancyStrategy,withTenant};
|
|
2
|
+
var __legacyDecorateClassTS=function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};import{Application,Service as Service2,ServiceNotFoundError as ServiceNotFoundError2}from"@elysiumjs/core";import{Service,ServiceNotFoundError}from"@elysiumjs/core";import{createCache}from"cache-manager";import{Keyv}from"keyv";import{alphabetical}from"radash";var REDIS_CACHE_SERVICE="mnemosyne.cache.redis",MEMORY_CACHE_SERVICE="mnemosyne.cache.memory",makeCacheInterface=(createStore)=>{let makeNamespace=(namespace)=>createCache({stores:[new Keyv({store:createStore(),namespace})]}),base=makeNamespace("cache"),tagsCache=new Map;return{...base,tags(...tags){let namespace=`cache__${alphabetical(tags,(item)=>item).join("__")}`,cache=tagsCache.get(namespace);if(!cache)cache=makeNamespace(namespace),tagsCache.set(namespace,cache);return cache}}},getCache=(name)=>{let cache=Service.get(name);if(!cache)throw new ServiceNotFoundError(name);return cache};class Cache{static weak=Object.seal({map:new WeakMap,get(ctx){if(!this.map.has(ctx))this.map.set(ctx,{});return this.map.get(ctx)},del(ctx){this.map.delete(ctx)}});static get redis(){return getCache(REDIS_CACHE_SERVICE)}static get memory(){return getCache(MEMORY_CACHE_SERVICE)}}import{DATABASE_TRANSACTION_SERVICE}from"@elysiumjs/core";var createDatabaseCacheStorage=(config)=>Cache[config.storage??"redis"].tags("database");class AbstractDatabase{static getConnectionName(name){return`db.connection.${name}`}getConnection(name="default"){if(name==="default"){let legacy=Application.context.getStore()?.get("db:tx");if(legacy)return legacy}let connection=this.getBaseConnection(name);return this.getActiveTransaction(connection)??connection}getBaseConnection(name="default"){let serviceName=AbstractDatabase.getConnectionName(name),connection=Service2.get(serviceName);if(connection===null)throw new ServiceNotFoundError2(serviceName);return connection}inTransaction(name="default"){return this.getActiveTransaction(this.getBaseConnection(name))!==void 0}async transaction(callback,name="default"){let connection=this.getBaseConnection(name),active=this.getActiveTransaction(connection);if(active!==void 0)return callback(active);return this.driver.withTransaction(connection,(tx)=>this.runInTransaction(connection,tx,()=>callback(tx)))}runInTransaction(connection,tx,callback){let store=Application.context.getStore(),transactions=new Map(store?.get("db:transactions"));transactions.set(connection,tx);let next=new Map(store);if(next.set("db:transactions",transactions),this.isDefaultConnection(connection))next.set("db:tx",tx);return Application.context.run(next,callback)}getActiveTransaction(connection){return Application.context.getStore()?.get("db:transactions")?.get(connection)}isDefaultConnection(connection){return Service2.get(AbstractDatabase.getConnectionName("default"))===connection}connectionExists(name){return Service2.exists(AbstractDatabase.getConnectionName(name))}getDefaultConnection(){return this.getConnection("default")}getRawClient(name="default"){return this.driver.getRawClient(this.getConnection(name))}}import{Application as Application3}from"@elysiumjs/core";import{t}from"elysia";import{Application as Application2,Middleware,Service as Service3}from"@elysiumjs/core";var TENANCY_CONFIG_SERVICE="mnemosyne.tenancy.config",TENANCY_STRATEGY_SERVICE="mnemosyne.tenancy.strategy",getTenancyConfig=()=>Service3.get(TENANCY_CONFIG_SERVICE)??{mode:"schema"},getTenancyStrategy=()=>Service3.get(TENANCY_STRATEGY_SERVICE),getCurrentTenant=()=>Application2.context.getStore()?.get("tenant")??null,withTenant=(tenant,callback)=>{let currentStore=Application2.context.getStore(),newStore=new Map(currentStore);return newStore.set("tenant",tenant),Application2.context.run(newStore,callback)},TENANT_ID_PATTERN=/^(?!pg_)(?!information_schema$)[a-z0-9_-]{1,63}$/i,isValidTenantId=(tenant)=>TENANT_ID_PATTERN.test(tenant);class TenantMiddleware extends Middleware{header="x-tenant-id";allowPublic=!0;connectionName="default";async validateTenant(_tenant){return!1}async onTenantResolved(_tenant,_ctx){}onTenantError(reason,_ctx){if(reason==="missing")throw Error("Tenant ID required");else throw Error("Invalid tenant")}async onRequest(ctx){let tenantId=ctx.request.headers.get(this.header);if(!tenantId||tenantId==="public"){if(this.allowPublic){ctx.tenant="public";return}return this.onTenantError("missing",ctx)}if(!(isValidTenantId(tenantId)&&await this.validateTenant(tenantId)))return this.onTenantError("invalid",ctx);ctx.tenant=tenantId,await this.onTenantResolved(tenantId,ctx)}}class SimpleTenantMiddleware extends TenantMiddleware{}class StrictTenantMiddleware extends TenantMiddleware{allowPublic=!1}var createSchemaFromModel=(columns,{mode="select"}={})=>{if(columns===void 0||columns.length===0)return t.Object({});let properties={};for(let column of columns){if(["create","update"].includes(mode)&&column.isPrimaryKey)continue;if(properties[column.name]=parseColumnType(column),column.nullable)properties[column.name]=t.Nullable(properties[column.name]);if(mode==="update"||column.hasDefault||column.nullable)properties[column.name]=t.Optional(properties[column.name])}return t.Object(properties)},parseColumnType=(column)=>{switch(column.dataType){case"string":return t.String();case"uuid":return t.String({format:"uuid"});case"number":return t.Number();case"boolean":return t.Boolean();case"array":return t.Array(t.Any());case"json":return t.Object({});case"date":return t.Transform(t.String({format:"date"})).Decode((date)=>new Date(date)).Encode((date)=>date.toISOString());case"datetime":return t.Transform(t.String({format:"date-time"})).Decode((date)=>new Date(date)).Encode((date)=>date.toISOString());case"bigint":return t.BigInt();case"buffer":return t.File();default:return t.Never()}},AbstractModel=(tableName,columns,adapter,baseTable)=>{let columnMetadata=adapter.getColumns(baseTable);class M{static $inferSelect=void 0;static $inferInsert=void 0;static $inferUpdate=void 0;static get table(){if(!Application3.instance||!Application3.context.getStore)return baseTable;if(this.supportTenancy){let tenant=getCurrentTenant()??"public";if(tenant!=="public"){let strategy=getTenancyStrategy();if(strategy)return strategy.resolveTable(baseTable,tenant);return adapter.createTenantTable(baseTable,tenant)}}return baseTable}static tableName=tableName;static columns=columns;static insertSchema=createSchemaFromModel(columnMetadata,{mode:"create"});static updateSchema=createSchemaFromModel(columnMetadata,{mode:"update"});static selectSchema=createSchemaFromModel(columnMetadata,{mode:"select"});static supportTenancy=!1}return M};var AbstractRepository=(model,database)=>{class R{static Model=model;static connection="default";get db(){return database.getConnection(this.constructor.connection)}}return R};import{Module}from"@elysiumjs/core";import{KeyvCacheableMemory}from"cacheable";import{EventEmitter}from"events";import{Event,Redis,Service as Service4}from"@elysiumjs/core";class KeyvRedis extends EventEmitter{#client;#namespace;#keyPrefixSeparator="::";#clearBatchSize=1000;#useUnlink=!0;#noNamespaceAffectsAll=!1;#connection;constructor(props){super();this.#connection=props.connection,this.#namespace=props.namespace,this.#keyPrefixSeparator=props.keyPrefixSeparator??"::",this.#clearBatchSize=props.clearBatchSize??1000,this.#useUnlink=props.useUnlink??!0,this.#noNamespaceAffectsAll=props.noNamespaceAffectsAll??!1,this.on("error",(error)=>{Event.emit("elysium:error",error)})}async getClient(){return this.#client??=await Service4.resolve(Redis.getConnectionName(this.#connection)),this.#client}get opts(){return{namespace:this.#namespace,keyPrefixSeparator:this.#keyPrefixSeparator,clearBatchSize:this.#clearBatchSize,noNamespaceAffectsAll:this.#noNamespaceAffectsAll,dialect:"redis",url:this.#connection}}get namespace(){return this.#namespace}set namespace(value){this.#namespace=value}async get(key){let client=await this.getClient();key=this.createKeyPrefix(key,this.#namespace);let value=await client.get(key);return value===null?void 0:value}async set(key,value,ttl){let client=await this.getClient();if(key=this.createKeyPrefix(key,this.#namespace),ttl)await client.set(key,value,"PX",ttl);else await client.set(key,value)}async setMany(values){let client=await this.getClient(),multi=[];for(let{key,value,ttl}of values){let prefixedKey=this.createKeyPrefix(key,this.#namespace);if(ttl)multi.push(client.set(prefixedKey,value,"PX",ttl));else multi.push(client.set(prefixedKey,value))}await Promise.all(multi)}async delete(key){let client=await this.getClient();return key=this.createKeyPrefix(key,this.#namespace),await this.#remove(client,[key])>0}async clear(){try{let client=await this.getClient();if(!this.#namespace&&this.#noNamespaceAffectsAll){await client.send("FLUSHDB",[]);return}let cursor="0",batchSize=this.#clearBatchSize,match=this.#namespace?`${this.#namespace}${this.#keyPrefixSeparator}*`:"*";do{let result=await client.send("SCAN",[cursor,"MATCH",match,"COUNT",batchSize.toString(),"TYPE","string"]),[newCursor,keys]=result;if(cursor=newCursor.toString(),keys.length===0)continue;if(!this.#namespace)keys=keys.filter((key)=>!key.includes(this.#keyPrefixSeparator));if(keys.length>0)await this.#remove(client,keys)}while(cursor!=="0")}catch(error){this.emit("error",error)}}async has(key){let client=await this.getClient();return key=this.createKeyPrefix(key,this.#namespace),client.exists(key)}async hasMany(keys){let client=await this.getClient(),multi=[];for(let key of keys){let prefixedKey=this.createKeyPrefix(key,this.#namespace);multi.push(client.exists(prefixedKey))}return Promise.all(multi)}async getMany(keys){let client=await this.getClient();if(keys.length===0)return[];return keys=keys.map((key)=>this.createKeyPrefix(key,this.#namespace)),await client.mget(...keys)}async disconnect(){this.#client=void 0}async deleteMany(keys){if(keys.length===0)return!1;let client=await this.getClient(),prefixedKeys=keys.map((key)=>this.createKeyPrefix(key,this.#namespace));return await this.#remove(client,prefixedKeys)>0}#remove(client,keys){return this.#useUnlink?client.unlink(...keys):client.del(...keys)}async*iterator(namespace){let client=await this.getClient(),match=namespace?`${namespace}${this.#keyPrefixSeparator}*`:"*",cursor="0";do{let result=await client.send("SCAN",[cursor,"MATCH",match,"TYPE","string"]),[newCursor,keys]=result;if(cursor=newCursor.toString(),!namespace&&!this.#noNamespaceAffectsAll)keys=keys.filter((key)=>!key.includes(this.#keyPrefixSeparator));if(keys.length>0){let values=await client.mget(...keys);for(let i of keys.keys()){let key=this.getKeyWithoutPrefix(keys[i],namespace),value=values[i];yield[key,value]}}}while(cursor!=="0")}createKeyPrefix(key,namespace){return namespace?`${namespace}${this.#keyPrefixSeparator}${key}`:key}getKeyWithoutPrefix(key,namespace){return namespace?key.replace(`${namespace}${this.#keyPrefixSeparator}`,""):key}}class MnemosyneModule extends Module.configurable({defaults:{cache:{redis:{connection:"cache"},memory:{ttl:60000,lruSize:5000}}}}){}MnemosyneModule=__legacyDecorateClassTS([Module.register({services:(config)=>{let services=[],{redis,memory}=config.cache;if(redis)services.push({name:REDIS_CACHE_SERVICE,global:!0,factory:()=>makeCacheInterface(()=>new KeyvRedis({connection:redis.connection}))});if(memory)services.push({name:MEMORY_CACHE_SERVICE,global:!0,factory:()=>makeCacheInterface(()=>new KeyvCacheableMemory({ttl:memory.ttl,lruSize:memory.lruSize}))});return services}})],MnemosyneModule);export{AbstractDatabase,AbstractModel,AbstractRepository,Cache,DATABASE_TRANSACTION_SERVICE,KeyvRedis,MEMORY_CACHE_SERVICE,MnemosyneModule,REDIS_CACHE_SERVICE,SimpleTenantMiddleware,StrictTenantMiddleware,TENANCY_CONFIG_SERVICE,TENANCY_STRATEGY_SERVICE,TENANT_ID_PATTERN,TenantMiddleware,createDatabaseCacheStorage,createSchemaFromModel,getCurrentTenant,getTenancyConfig,getTenancyStrategy,isValidTenantId,withTenant};
|
package/dist/interfaces.d.ts
CHANGED
|
@@ -13,9 +13,10 @@ export interface DatabaseDriver<TConnection = unknown, TConnectionConfig = unkno
|
|
|
13
13
|
/**
|
|
14
14
|
* Creates a new database connection from the given configuration.
|
|
15
15
|
* @param config The connection configuration.
|
|
16
|
+
* @param name The name of the connection, when the driver scopes resources by connection.
|
|
16
17
|
* @returns A new database connection.
|
|
17
18
|
*/
|
|
18
|
-
createConnection(config: TConnectionConfig): TConnection;
|
|
19
|
+
createConnection(config: TConnectionConfig, name?: string): TConnection;
|
|
19
20
|
/**
|
|
20
21
|
* Executes a callback within a database transaction.
|
|
21
22
|
* @param connection The connection to use for the transaction.
|
|
@@ -165,7 +166,9 @@ export interface DatabaseCacheConfig {
|
|
|
165
166
|
*/
|
|
166
167
|
enabled?: boolean;
|
|
167
168
|
/**
|
|
168
|
-
* The default TTL for cached data, in seconds.
|
|
169
|
+
* The default TTL for cached data, in seconds. Drivers apply it to every cached result that
|
|
170
|
+
* does not set its own expiry.
|
|
171
|
+
* @default 60
|
|
169
172
|
*/
|
|
170
173
|
ttl?: number;
|
|
171
174
|
/**
|
|
@@ -178,6 +181,13 @@ export interface DatabaseCacheConfig {
|
|
|
178
181
|
* The storage engine to use for the cache.
|
|
179
182
|
*/
|
|
180
183
|
storage?: 'redis' | 'memory';
|
|
184
|
+
/**
|
|
185
|
+
* A prefix for every cache key, before the connection name. Set it when several applications
|
|
186
|
+
* or environments share one cache store, so that connections with the same name but different
|
|
187
|
+
* databases do not share results.
|
|
188
|
+
* @default ''
|
|
189
|
+
*/
|
|
190
|
+
prefix?: string;
|
|
181
191
|
}
|
|
182
192
|
/**
|
|
183
193
|
* Database primary column type.
|
|
@@ -236,9 +246,9 @@ export interface RepositoryInterface<TSelect, TInsert, TUpdate, TId extends IdTy
|
|
|
236
246
|
* Updates a record in the database.
|
|
237
247
|
* @param id The id of the record to update.
|
|
238
248
|
* @param data The data to update.
|
|
239
|
-
* @returns The updated record.
|
|
249
|
+
* @returns The updated record, or `null` if no record has that id.
|
|
240
250
|
*/
|
|
241
|
-
update(id: TId, data: TUpdate): Promise<TSelect>;
|
|
251
|
+
update(id: TId, data: TUpdate): Promise<TSelect | null>;
|
|
242
252
|
/**
|
|
243
253
|
* Updates all the records in the database.
|
|
244
254
|
* @param data The data to update.
|
|
@@ -248,9 +258,9 @@ export interface RepositoryInterface<TSelect, TInsert, TUpdate, TId extends IdTy
|
|
|
248
258
|
/**
|
|
249
259
|
* Deletes a record from the database.
|
|
250
260
|
* @param id The ID of the record to delete.
|
|
251
|
-
* @returns The deleted record.
|
|
261
|
+
* @returns The deleted record, or `null` if no record has that id.
|
|
252
262
|
*/
|
|
253
|
-
delete(id: TId): Promise<TSelect>;
|
|
263
|
+
delete(id: TId): Promise<TSelect | null>;
|
|
254
264
|
/**
|
|
255
265
|
* Deletes all the records from the database.
|
|
256
266
|
* @returns All the deleted records.
|
package/dist/keyv-redis.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export declare class KeyvRedis extends EventEmitter implements KeyvStoreAdapter
|
|
|
25
25
|
*/
|
|
26
26
|
clearBatchSize?: number;
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
28
|
+
* Use UNLINK instead of DEL to delete keys. UNLINK frees memory in the background (Redis 4+).
|
|
29
29
|
*/
|
|
30
30
|
useUnlink?: boolean;
|
|
31
31
|
/**
|
package/dist/repository.d.ts
CHANGED
|
@@ -56,9 +56,9 @@ export declare const AbstractRepository: <TModel extends ModelClass, TConnection
|
|
|
56
56
|
* Updates a record in the database.
|
|
57
57
|
* @param id The id of the record to update.
|
|
58
58
|
* @param data The data to update.
|
|
59
|
-
* @returns The updated record.
|
|
59
|
+
* @returns The updated record, or `null` if no record has that id.
|
|
60
60
|
*/
|
|
61
|
-
update(id: IdType, data: TModel["$inferUpdate"]): Promise<TModel["$inferSelect"]>;
|
|
61
|
+
update(id: IdType, data: TModel["$inferUpdate"]): Promise<TModel["$inferSelect"] | null>;
|
|
62
62
|
/**
|
|
63
63
|
* Updates all the records in the database.
|
|
64
64
|
* @param data The data to update.
|
|
@@ -68,9 +68,9 @@ export declare const AbstractRepository: <TModel extends ModelClass, TConnection
|
|
|
68
68
|
/**
|
|
69
69
|
* Deletes a record from the database.
|
|
70
70
|
* @param id The ID of the record to delete.
|
|
71
|
-
* @returns The deleted record.
|
|
71
|
+
* @returns The deleted record, or `null` if no record has that id.
|
|
72
72
|
*/
|
|
73
|
-
delete(id: IdType): Promise<TModel["$inferSelect"]>;
|
|
73
|
+
delete(id: IdType): Promise<TModel["$inferSelect"] | null>;
|
|
74
74
|
/**
|
|
75
75
|
* Deletes all the records from the database.
|
|
76
76
|
* @returns All the deleted records.
|
package/dist/tenancy.d.ts
CHANGED
|
@@ -100,11 +100,30 @@ export declare const getCurrentTenant: () => string | null;
|
|
|
100
100
|
* @returns The return value of the callback.
|
|
101
101
|
*/
|
|
102
102
|
export declare const withTenant: <T>(tenant: string, callback: () => T) => T;
|
|
103
|
+
/**
|
|
104
|
+
* The pattern a tenant identifier must match: 1 to 63 ASCII letters, digits, underscores or dashes,
|
|
105
|
+
* not starting with `pg_` and other than `information_schema` (in any case).
|
|
106
|
+
*
|
|
107
|
+
* Tenant identifiers become PostgreSQL schema names in schema mode, and 63 bytes is the PostgreSQL
|
|
108
|
+
* identifier limit. The pattern leaves out quotes, dots, spaces and other characters that would
|
|
109
|
+
* need escaping or change how an identifier is read, and the names of PostgreSQL system schemas.
|
|
110
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
111
|
+
*/
|
|
112
|
+
export declare const TENANT_ID_PATTERN: RegExp;
|
|
113
|
+
/**
|
|
114
|
+
* Checks that a tenant identifier is well-formed (see {@link TENANT_ID_PATTERN}). This does not
|
|
115
|
+
* check that the tenant exists.
|
|
116
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
117
|
+
* @param tenant The tenant identifier.
|
|
118
|
+
* @returns `true` if the identifier is well-formed, `false` otherwise.
|
|
119
|
+
*/
|
|
120
|
+
export declare const isValidTenantId: (tenant: string) => boolean;
|
|
103
121
|
/**
|
|
104
122
|
* Base class for tenant-aware middleware.
|
|
105
123
|
*
|
|
106
124
|
* The middleware extracts the tenant identifier from an HTTP header and
|
|
107
|
-
* sets `ctx.tenant`.
|
|
125
|
+
* sets `ctx.tenant`. Identifiers must match {@link TENANT_ID_PATTERN} and be accepted by
|
|
126
|
+
* `validateTenant`, which rejects every tenant unless overridden. For RLS mode, route handlers should use the driver's
|
|
108
127
|
* isolation mechanism to wrap their queries in a tenant-scoped transaction.
|
|
109
128
|
*
|
|
110
129
|
* @example
|
|
@@ -136,7 +155,9 @@ export declare abstract class TenantMiddleware extends Middleware {
|
|
|
136
155
|
protected connectionName: string;
|
|
137
156
|
/**
|
|
138
157
|
* Validates that a tenant identifier is legitimate.
|
|
139
|
-
*
|
|
158
|
+
*
|
|
159
|
+
* Rejects every tenant by default: override it to check the tenant against your data store.
|
|
160
|
+
* Only identifiers matching {@link TENANT_ID_PATTERN} reach this method.
|
|
140
161
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
141
162
|
* @param _tenant The tenant identifier to validate.
|
|
142
163
|
* @returns `true` if the tenant is valid, `false` otherwise.
|
|
@@ -165,7 +186,8 @@ export declare abstract class TenantMiddleware extends Middleware {
|
|
|
165
186
|
onRequest(ctx: Context): Promise<any>;
|
|
166
187
|
}
|
|
167
188
|
/**
|
|
168
|
-
* A simple tenant middleware that allows public access by default.
|
|
189
|
+
* A simple tenant middleware that allows public access by default. Requests naming a tenant are
|
|
190
|
+
* rejected until `validateTenant` is overridden.
|
|
169
191
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
170
192
|
*/
|
|
171
193
|
export declare class SimpleTenantMiddleware extends TenantMiddleware {
|
package/package.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elysiumjs/mnemosyne",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Database and caching for Elysium.js applications",
|
|
6
6
|
"private": false,
|
|
7
7
|
"author": "Workbud Technologies Inc.",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "https://github.com/
|
|
10
|
+
"url": "git+https://github.com/na2axl/elysiumjs.git",
|
|
11
|
+
"directory": "@elysiumjs/mnemosyne"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/na2axl/elysiumjs/tree/main/@elysiumjs/mnemosyne#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/na2axl/elysiumjs/issues"
|
|
11
16
|
},
|
|
12
|
-
"homepage": "https://github.com/workbud/elysium",
|
|
13
17
|
"scripts": {
|
|
14
18
|
"format": "prettier --write \"./src/**/*.ts\"",
|
|
15
19
|
"clean": "rimraf dist",
|
|
@@ -17,7 +21,9 @@
|
|
|
17
21
|
"build:ts": "tsc",
|
|
18
22
|
"build": "bun run clean && bun run build:js && bun run build:ts",
|
|
19
23
|
"typecheck": "tsc --noEmit",
|
|
20
|
-
"prepublishOnly": "bun run build"
|
|
24
|
+
"prepublishOnly": "bun run build",
|
|
25
|
+
"test": "bun test",
|
|
26
|
+
"test:coverage": "bun test --coverage"
|
|
21
27
|
},
|
|
22
28
|
"keywords": [
|
|
23
29
|
"elysium",
|
|
@@ -28,12 +34,16 @@
|
|
|
28
34
|
"module": "dist/index.js",
|
|
29
35
|
"type": "module",
|
|
30
36
|
"types": "dist/index.d.ts",
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"CHANGELOG.md"
|
|
40
|
+
],
|
|
31
41
|
"sideEffects": false,
|
|
32
42
|
"peerDependencies": {
|
|
33
|
-
"@elysiumjs/core": "^0.
|
|
43
|
+
"@elysiumjs/core": "^0.10.0"
|
|
34
44
|
},
|
|
35
45
|
"devDependencies": {
|
|
36
|
-
"@elysiumjs/core": "0.
|
|
46
|
+
"@elysiumjs/core": "0.10.0"
|
|
37
47
|
},
|
|
38
48
|
"dependencies": {
|
|
39
49
|
"cache-manager": "^7.2.9",
|
package/.turbo/turbo-build.log
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
$ bun run clean && bun run build:js && bun run build:ts
|
|
2
|
-
$ rimraf dist
|
|
3
|
-
$ bun build ./src/index.ts --outdir ./dist --target bun --packages external --root ./src --minify-syntax --minify-whitespace
|
|
4
|
-
Bundled 8 modules in 21ms
|
|
5
|
-
|
|
6
|
-
index.js 11.16 KB (entry point)
|
|
7
|
-
|
|
8
|
-
$ tsc
|