@elysiumjs/mnemosyne 0.1.2 → 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 +90 -0
- package/README.md +19 -16
- package/dist/cache.d.ts +134 -0
- package/dist/database.d.ts +127 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +2 -0
- package/dist/interfaces.d.ts +360 -0
- package/dist/keyv-redis.d.ts +88 -0
- package/dist/mnemosyne.module.d.ts +44 -0
- package/dist/model.d.ts +68 -0
- package/dist/repository.d.ts +96 -0
- package/dist/tenancy.d.ts +201 -0
- package/package.json +21 -12
- package/bunfig.toml +0 -19
- package/src/cache.ts +0 -218
- package/src/database.ts +0 -151
- package/src/index.ts +0 -47
- package/src/interfaces.ts +0 -449
- package/src/keyv-redis.ts +0 -313
- package/src/model.ts +0 -189
- package/src/repository.ts +0 -137
- package/src/tenancy.ts +0 -291
- package/tests/cache.test.ts +0 -464
- package/tests/database.test.ts +0 -249
- package/tests/model.test.ts +0 -234
- package/tests/repository.test.ts +0 -177
- package/tests/setup.ts +0 -26
- package/tsconfig.json +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,95 @@
|
|
|
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
|
+
|
|
59
|
+
## 0.2.0
|
|
60
|
+
|
|
61
|
+
### Minor Changes
|
|
62
|
+
|
|
63
|
+
- [`3625883`](https://github.com/na2axl/elysiumjs/commit/3625883dba92f1596e7444ee52f9399d8e9b049f) Thanks [@na2axl](https://github.com/na2axl)! - Drop the `reflect-metadata` dependency. Decorator metadata now lives in a small store in `@elysiumjs/core`, exported as `Metadata` (`define`, `get`, `getOwn`, `has`). Use it instead of `Reflect.defineMetadata` and `Reflect.getMetadata` to read or write Elysium metadata.
|
|
64
|
+
|
|
65
|
+
Apps no longer need `import 'reflect-metadata'` in their entrypoint, and generated projects leave it out. Keep `emitDecoratorMetadata` enabled: `@Service.inject()` and `@Command.arg()` still infer types from it. An app that loads `reflect-metadata` for another library keeps working; Elysium also reads the types stored there.
|
|
66
|
+
|
|
67
|
+
- [`07236b3`](https://github.com/na2axl/elysiumjs/commit/07236b386c2506590d74e8753ce847c1e184b3ee) Thanks [@na2axl](https://github.com/na2axl)! - Introduce the module system. Modules are now the only way to register anything in an Elysium.js app. This is a breaking change with no compatibility layer.
|
|
68
|
+
|
|
69
|
+
Breaking changes:
|
|
70
|
+
|
|
71
|
+
- `@Module.register()` takes `imports`, `services`, `exports`, `controllers`, `commands`, `jobs`, `prefix` and `env`. Services are private to their module unless exported (or global). The `beforeRegister` and `afterRegister` hooks are replaced by `onInit`, `onServe` and `onDestroy`.
|
|
72
|
+
- Configurable modules are bound with `with()`, for example `HermesModule.with({ level: 'debug' })`. `with()` accepts an object or a function.
|
|
73
|
+
- Decorators only record metadata. Importing a file no longer registers commands, services or event wiring, and the side-effect imports of the packages are removed. List commands, jobs and services in the module that owns them.
|
|
74
|
+
- `@Application.register()` no longer takes `database`, `redis`, `wamp` or `'elysium:hermes'`/`'elysium:cerberus'` keys. Use `DrizzleModule`, `RedisModule`, `WampModule`, `HermesModule` and `CerberusModule`. Package plugins are replaced by modules: add `SentryModule` instead of the `plugin()` function. `plugins` still accepts plain Elysia plugin factories.
|
|
75
|
+
- Constructing an `Application` no longer runs it. Use `await App.start()`; `Application.testing()` boots a module graph for tests. The `styx` binary loads `src/app.ts` (or `index.ts`) and starts the exported application, so a project entry no longer needs to import commands.
|
|
76
|
+
- The job system of core (`Job`, `Worker`, `WorkerPool`, `commandWork`) is removed. `@elysiumjs/heracles` is the only job system: extend its `Job`, list jobs in a module's `jobs`, declare queues with `HeraclesModule.with({ queues })`, and dispatch with `Heracles.dispatch()`.
|
|
77
|
+
- `@elysiumjs/styx` ships `StyxModule`, active in development only. The `migration:generate` and `migration:run` commands move to `DrizzleModule` and are available in production.
|
|
78
|
+
- `@elysiumjs/mnemosyne` ships `MnemosyneModule` for its caches (`redis: false` or `memory: false` opts out). `DrizzleModule` imports it.
|
|
79
|
+
- New modules: `HephaestusModule`, `ArgusModule` (`serviceName` is required), `SentryModule` (needs a `dsn` or `SENTRY_DSN`) and `CerberusModule` (`getSubject` is required).
|
|
80
|
+
- Module and service code throws typed errors (for example `InvalidModuleConfigError`) instead of exiting the process. `Application.run()` prints them and exits; command `run()` methods may still exit, and package plugin factories are unchanged.
|
|
81
|
+
- `Application.register` merges subclass props over the parent's (own props, then own metadata, then parent, then defaults), and concatenates `modules` parent first. Previously defaults and parent values won, so `openapi` could not be enabled.
|
|
82
|
+
- `Heracles.dispatch` starts a stopped queue on first use, so producers no longer start queues. `Queue.start()` is idempotent.
|
|
83
|
+
- `create-elysium` generates apps that use modules, and adds Sentry and Hephaestus to the `modules` list.
|
|
84
|
+
- `@Event.on`/`@Event.once` method listeners on classes in a loaded module's `services` or `controllers` are wired by the kernel and run in their module's context. Jobs, commands and middlewares are not wired, and an instance listener on a class that is not a service throws `EventListenerNotSupportedError`. Listeners on classes not listed in a loaded module no longer fire.
|
|
85
|
+
- New typed errors: `CircularDependencyError` (services that depend on each other, in sync and async resolution), `DuplicateModuleNameError` (two different module classes sharing a name; module scopes and config tokens are name based) and `EventListenerNotSupportedError`.
|
|
86
|
+
- Lazy boot contract: a CLI command initialises only its own module and that module's imports. Sync helpers such as `Database.getConnection()` work when the owning module's `onInit` is synchronous; otherwise they throw `ServiceNotReadyError` naming the module. Use `await Service.resolve()` or set `static boot = true` on the command.
|
|
87
|
+
|
|
88
|
+
### Patch Changes
|
|
89
|
+
|
|
90
|
+
- Updated dependencies [[`8ac91ab`](https://github.com/na2axl/elysiumjs/commit/8ac91ab676b1119b4eecf0ddb3fd0a3e84017cac), [`3625883`](https://github.com/na2axl/elysiumjs/commit/3625883dba92f1596e7444ee52f9399d8e9b049f), [`07236b3`](https://github.com/na2axl/elysiumjs/commit/07236b386c2506590d74e8753ce847c1e184b3ee)]:
|
|
91
|
+
- @elysiumjs/core@0.8.0
|
|
92
|
+
|
|
3
93
|
## 0.1.0
|
|
4
94
|
|
|
5
95
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @elysiumjs/mnemosyne
|
|
2
2
|
|
|
3
|
-
Database and caching for Elysium.js applications.
|
|
3
|
+
Database abstractions and caching for Elysium.js applications.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -10,24 +10,27 @@ bun add @elysiumjs/mnemosyne
|
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
|
+
`MnemosyneModule` provides the caches (`Cache.redis` and `Cache.memory`). Database driver modules such as `DrizzleModule` from `@elysiumjs/mnemosyne-drizzle` import it for you, so you only add it yourself when you want to configure the caches or use them without a database:
|
|
14
|
+
|
|
13
15
|
```ts
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class UserRepository extends Repository(UserModel) {}
|
|
16
|
+
import { Application, RedisModule } from '@elysiumjs/core';
|
|
17
|
+
import { MnemosyneModule } from '@elysiumjs/mnemosyne';
|
|
18
|
+
|
|
19
|
+
@Application.register({
|
|
20
|
+
modules: [
|
|
21
|
+
RedisModule.with({
|
|
22
|
+
default: 'cache',
|
|
23
|
+
connections: { cache: { url: process.env.REDIS_URL! } }
|
|
24
|
+
}),
|
|
25
|
+
// Opt out of a cache with `redis: false` or `memory: false`.
|
|
26
|
+
MnemosyneModule.with({ cache: { memory: { ttl: 60_000, lruSize: 5000 } } })
|
|
27
|
+
]
|
|
28
|
+
})
|
|
29
|
+
export class App extends Application {}
|
|
29
30
|
```
|
|
30
31
|
|
|
32
|
+
Models and repositories are built with a driver package. See [`@elysiumjs/mnemosyne-drizzle`](../mnemosyne-drizzle/README.md).
|
|
33
|
+
|
|
31
34
|
## License
|
|
32
35
|
|
|
33
36
|
This project is licensed under the Apache License, Version 2.0. See the [LICENSE](../../LICENSE) file for more info.
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { KeyvStoreAdapter } from 'keyv';
|
|
2
|
+
/** Name of the global service backing `Cache.redis`. */
|
|
3
|
+
export declare const REDIS_CACHE_SERVICE = "mnemosyne.cache.redis";
|
|
4
|
+
/** Name of the global service backing `Cache.memory`. */
|
|
5
|
+
export declare const MEMORY_CACHE_SERVICE = "mnemosyne.cache.memory";
|
|
6
|
+
/**
|
|
7
|
+
* Interface for a cache storage.
|
|
8
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
9
|
+
*/
|
|
10
|
+
export interface CacheInterface {
|
|
11
|
+
/**
|
|
12
|
+
* Retrieves the cached data for a given key.
|
|
13
|
+
* @typeParam T The type of the cached data.
|
|
14
|
+
*
|
|
15
|
+
* @param key The key of the cached data.
|
|
16
|
+
*/
|
|
17
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
18
|
+
/**
|
|
19
|
+
* Retrieves the cached data for a given list of keys.
|
|
20
|
+
* @typeParam T The type of the cached data.
|
|
21
|
+
*
|
|
22
|
+
* @param keys The list of cached data keys.
|
|
23
|
+
*/
|
|
24
|
+
mget<T>(keys: string[]): Promise<Array<T | undefined>>;
|
|
25
|
+
/**
|
|
26
|
+
* Gets the TTL of the cached data with the given key.
|
|
27
|
+
* @param key The key of the cached data.
|
|
28
|
+
* @returns The TTL of the cached data, or `undefined` if the data does not exist.
|
|
29
|
+
*/
|
|
30
|
+
ttl(key: string): Promise<number | undefined>;
|
|
31
|
+
/**
|
|
32
|
+
* Caches data under the given key and an optional TTL.
|
|
33
|
+
* @typeParam T The type of the data to cache.
|
|
34
|
+
*
|
|
35
|
+
* @param key The key of the cached data.
|
|
36
|
+
* @param data The data to cache.
|
|
37
|
+
* @param ttl The optional TTL of the cached data. If not defined, the data is cached forever.
|
|
38
|
+
* @returns The cached data.
|
|
39
|
+
*/
|
|
40
|
+
set<T>(key: string, data: T, ttl?: number): Promise<T>;
|
|
41
|
+
/**
|
|
42
|
+
* Caches data under the given list of keys and an optional TTL.
|
|
43
|
+
* @typeParam T The type of the data to cache.
|
|
44
|
+
*
|
|
45
|
+
* @param data The list of data to cache.
|
|
46
|
+
* @returns The list of cached data.
|
|
47
|
+
*/
|
|
48
|
+
mset<T>(data: Array<{
|
|
49
|
+
key: string;
|
|
50
|
+
value: T;
|
|
51
|
+
ttl?: number;
|
|
52
|
+
}>): Promise<Array<{
|
|
53
|
+
key: string;
|
|
54
|
+
value: T;
|
|
55
|
+
ttl?: number;
|
|
56
|
+
}>>;
|
|
57
|
+
/**
|
|
58
|
+
* Deletes the cached data with the given key.
|
|
59
|
+
* @typeParam T The type of the data to cache.
|
|
60
|
+
*
|
|
61
|
+
* @param key The key of the cached data.
|
|
62
|
+
* @returns `true` if the data was deleted, `false` otherwise.
|
|
63
|
+
*/
|
|
64
|
+
del(key: string): Promise<boolean>;
|
|
65
|
+
/**
|
|
66
|
+
* Deletes the cached data with the given list of keys.
|
|
67
|
+
* @typeParam T The type of the data to cache.
|
|
68
|
+
*
|
|
69
|
+
* @param keys The list of cached data keys.
|
|
70
|
+
* @returns `true` if the data was deleted, `false` otherwise.
|
|
71
|
+
*/
|
|
72
|
+
mdel(keys: string[]): Promise<boolean>;
|
|
73
|
+
/**
|
|
74
|
+
* Deletes all cached data.
|
|
75
|
+
* @returns `true` if the data was deleted, `false` otherwise.
|
|
76
|
+
*/
|
|
77
|
+
clear(): Promise<boolean>;
|
|
78
|
+
/**
|
|
79
|
+
* Creates a sub-cache with the given tags.
|
|
80
|
+
* @param tags The list of tags for the sub-cache.
|
|
81
|
+
*/
|
|
82
|
+
tags(...tags: string[]): Omit<CacheInterface, 'tags'>;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
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.
|
|
92
|
+
* @returns A new `CacheInterface`.
|
|
93
|
+
*/
|
|
94
|
+
export declare const makeCacheInterface: (createStore: () => KeyvStoreAdapter) => CacheInterface;
|
|
95
|
+
/**
|
|
96
|
+
* Static access to the caches provided by the `MnemosyneModule`.
|
|
97
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
98
|
+
*/
|
|
99
|
+
export declare abstract class Cache {
|
|
100
|
+
/**
|
|
101
|
+
* A weak cache storage for simple non-intrusive caching of data.
|
|
102
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
103
|
+
*/
|
|
104
|
+
static readonly weak: {
|
|
105
|
+
/**
|
|
106
|
+
* INTERNAL USAGE ONLY.
|
|
107
|
+
* @private
|
|
108
|
+
*/
|
|
109
|
+
map: WeakMap<object, unknown>;
|
|
110
|
+
/**
|
|
111
|
+
* Gets the cached data for a given context.
|
|
112
|
+
* @param ctx The cache context.
|
|
113
|
+
* @returns The cached data.
|
|
114
|
+
*/
|
|
115
|
+
get<T extends object>(ctx: object): T;
|
|
116
|
+
/**
|
|
117
|
+
* Deletes the cached data for a given context.
|
|
118
|
+
* @param ctx The cache context.
|
|
119
|
+
*/
|
|
120
|
+
del(ctx: object): void;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Redis-based cache storage.
|
|
124
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
125
|
+
* @throws {ServiceNotFoundError} If the `MnemosyneModule` is not loaded or has no redis cache.
|
|
126
|
+
*/
|
|
127
|
+
static get redis(): CacheInterface;
|
|
128
|
+
/**
|
|
129
|
+
* Memory-based cache storage.
|
|
130
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
131
|
+
* @throws {ServiceNotFoundError} If the `MnemosyneModule` is not loaded or has no memory cache.
|
|
132
|
+
*/
|
|
133
|
+
static get memory(): CacheInterface;
|
|
134
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { CacheInterface } from './cache';
|
|
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';
|
|
8
|
+
/**
|
|
9
|
+
* Creates a cache storage instance tagged for database use.
|
|
10
|
+
* Drivers can use this to build their cache backends.
|
|
11
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
12
|
+
* @param config The database cache configuration.
|
|
13
|
+
* @returns A cache interface tagged with 'database'.
|
|
14
|
+
*/
|
|
15
|
+
export declare const createDatabaseCacheStorage: (config: DatabaseCacheConfig) => Omit<CacheInterface, 'tags'>;
|
|
16
|
+
/**
|
|
17
|
+
* Abstract database manager.
|
|
18
|
+
*
|
|
19
|
+
* Retrieves named database connections using a driver. Connections are registered as
|
|
20
|
+
* global services `db.connection.{name}` by the driver modules.
|
|
21
|
+
*
|
|
22
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
23
|
+
* @template TConnection The connection type managed by the driver.
|
|
24
|
+
* @template TConnectionConfig The configuration used to create a connection.
|
|
25
|
+
*/
|
|
26
|
+
export declare abstract class AbstractDatabase<TConnection, TConnectionConfig> {
|
|
27
|
+
/**
|
|
28
|
+
* The database driver used to create and manage connections.
|
|
29
|
+
*/
|
|
30
|
+
protected abstract driver: DatabaseDriver<TConnection, TConnectionConfig>;
|
|
31
|
+
/**
|
|
32
|
+
* Creates a service name for a database connection. Driver modules register their
|
|
33
|
+
* connections (and the `default` alias) under this name.
|
|
34
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
35
|
+
* @param name The name of the connection.
|
|
36
|
+
* @returns A service name for the connection.
|
|
37
|
+
*/
|
|
38
|
+
static getConnectionName(name: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Retrieves the connection with the given name.
|
|
41
|
+
*
|
|
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.
|
|
46
|
+
* @throws {ServiceNotFoundError} If no connection with that name is registered.
|
|
47
|
+
*
|
|
48
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
49
|
+
* @param name The connection name.
|
|
50
|
+
* @returns The connection with the given name, or its open transaction.
|
|
51
|
+
*/
|
|
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;
|
|
107
|
+
/**
|
|
108
|
+
* Checks if a connection with the given name exists.
|
|
109
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
110
|
+
* @param name The name of the connection to check.
|
|
111
|
+
* @returns `true` if the connection exists, `false` otherwise.
|
|
112
|
+
*/
|
|
113
|
+
connectionExists(name: string): boolean;
|
|
114
|
+
/**
|
|
115
|
+
* Retrieves the default connection.
|
|
116
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
117
|
+
* @returns The default connection.
|
|
118
|
+
*/
|
|
119
|
+
getDefaultConnection(): TConnection;
|
|
120
|
+
/**
|
|
121
|
+
* Returns the raw underlying client for the given connection.
|
|
122
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
123
|
+
* @param name The connection name.
|
|
124
|
+
* @returns The raw client instance.
|
|
125
|
+
*/
|
|
126
|
+
getRawClient(name?: string): unknown;
|
|
127
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type { ColumnMetadata, DatabaseCacheConfig, DatabaseCacheStrategy, DatabaseDriver, IdType, ModelAdapter, ModelClass, RepositoryClass, RepositoryInterface, TenancyStrategy } from './interfaces';
|
|
2
|
+
export * from './database';
|
|
3
|
+
export * from './model';
|
|
4
|
+
export * from './repository';
|
|
5
|
+
export * from './tenancy';
|
|
6
|
+
export type { TenancyConfig, TenancyMode, ModelTenancyConfig } from './tenancy';
|
|
7
|
+
export { Cache, type CacheInterface, MEMORY_CACHE_SERVICE, REDIS_CACHE_SERVICE } from './cache';
|
|
8
|
+
export * from './mnemosyne.module';
|
|
9
|
+
export * from './keyv-redis';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
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=(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};
|