@elysiumjs/mnemosyne 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +165 -0
- package/README.md +12 -0
- package/dist/cache.d.ts +21 -4
- package/dist/cache.interceptor.d.ts +36 -0
- package/dist/database.d.ts +64 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +1 -1
- package/dist/interfaces.d.ts +17 -7
- package/dist/keyv-redis.d.ts +3 -2
- package/dist/mnemosyne.module.d.ts +9 -3
- package/dist/model.d.ts +4 -4
- package/dist/query.d.ts +150 -0
- package/dist/repository.d.ts +4 -4
- package/dist/resource.d.ts +35 -0
- package/dist/tenancy.d.ts +25 -3
- package/dist/unit-of-work.d.ts +68 -0
- package/package.json +18 -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,170 @@
|
|
|
1
1
|
# @elysiumjs/mnemosyne
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#7](https://github.com/na2axl/elysiumjs/pull/7) [`e9c3d98`](https://github.com/na2axl/elysiumjs/commit/e9c3d98dd2714b69100215f7c8a80046da463148) Thanks [@na2axl](https://github.com/na2axl)! - Elysium runs on Elysia 2 (`elysia@^2.0.0-rc.1`), with TypeBox 1 (`typebox`), `@elysia/openapi` and `@elysia/opentelemetry`.
|
|
8
|
+
|
|
9
|
+
Breaking:
|
|
10
|
+
|
|
11
|
+
- `TSchema` and `Static` are re-exported from `typebox` instead of `@sinclair/typebox`; `Value` comes from `typebox/value` and `Value.Errors()` returns an array.
|
|
12
|
+
- `Application.onError()` receives a `ServerError` (exported from core). Elysia now names its errors in kebab case; the default error response keeps the upper snake case names (`VALIDATION`, `NOT_FOUND`, `UNKNOWN` for other errors).
|
|
13
|
+
- `AppHttpContext` is a plain interface with `decorator`, `store` and `derive` records (Elysia 2 renamed `resolve` to `derive`).
|
|
14
|
+
- Websocket controllers install Elysia's `websocket()` capability themselves.
|
|
15
|
+
|
|
16
|
+
A module or controller `onRequest` middleware that answers now stops the request: Elysia 2 ignores values returned from a transform hook, so the response is thrown as a status and sent as is, without going through `onError`.
|
|
17
|
+
|
|
18
|
+
`mnemosyne` builds date columns with `t.Codec` (TypeBox 1 dropped `Transform`). `argus` traces every route of the app it is installed on and ends each span once, keeping the error status when the request failed.
|
|
19
|
+
|
|
20
|
+
- [#19](https://github.com/na2axl/elysiumjs/pull/19) [`6789879`](https://github.com/na2axl/elysiumjs/commit/678987976ecfd46b534b65276e0de3f448977562) Thanks [@na2axl](https://github.com/na2axl)! - Domain errors as classes with a code, returned as values.
|
|
21
|
+
|
|
22
|
+
- `DomainError` is an Elysia `HTTPError`. Each error is a class made with `DomainError.for(code, status)`, which keeps its code and status as literal types, with `errors`, `retryAfterSeconds` (served as `retryAfter` and the Retry-After header) and extensions.
|
|
23
|
+
- Ready-made errors: `BadRequestError`, `ValidationFailedError`, `UnauthenticatedError`, `ForbiddenError`, `NotFoundError` and `ConflictError`.
|
|
24
|
+
- `ProblemCatalogue`, extended through declaration merging, lists the codes of an application; once it has keys, `DomainError.for()` takes only those and `ELYSIUM_PROBLEM_CODES`.
|
|
25
|
+
- Every problem carries `code`, `requestId` (from `problems.requestId`, a valid `x-request-id` header, or a random UUID) and `instance`. Its `type` is the kebab-case slug of the code, under `problems.typeBase`.
|
|
26
|
+
- Errors as values: a handler, a flow step, `UnitOfWork.run()` or a `@Transactional()` method that returns a DomainError rolls back and sends or returns it like a thrown one. `Commands.execute()` and `Queries.ask()` return the DomainError that refused the call, and a returned error joins the route's response type for typed clients.
|
|
27
|
+
|
|
28
|
+
- [#13](https://github.com/na2axl/elysiumjs/pull/13) [`1ea417e`](https://github.com/na2axl/elysiumjs/commit/1ea417e3583250bc2f975d795f398b7ff736115d) Thanks [@na2axl](https://github.com/na2axl)! - Hardening of the runtimes, the container and the CLI, checked by a conformance suite that runs on Bun, Node.js and workerd.
|
|
29
|
+
|
|
30
|
+
Breaking changes:
|
|
31
|
+
|
|
32
|
+
- `Runtime` gains the required `argv()`, `write(stream, text)` and `terminal()` members: core reaches the host's arguments, output streams and terminal only through the runtime. Custom runtimes must implement them.
|
|
33
|
+
- The third parameter of `InteractsWithConsole.write` is now `'stdout' | 'stderr'`.
|
|
34
|
+
- The WebSocket options are narrowed to `idleTimeout`, `maxPayloadLength` and `perMessageDeflate`, and the `bun` WebSocket types are replaced by `ElysiumWebSocket`.
|
|
35
|
+
- Redis connections are `RedisConnection`s, a port any Redis client can implement, instead of `Bun.RedisClient`.
|
|
36
|
+
- `styx create @scope/name` creates the project in `name/`, and refuses project and module names with upper-case letters.
|
|
37
|
+
- Queue handlers no longer reject the batch: after the handlers ran, each message is acked or retried on its own. `@Workers.queue(name, { each: true })` receives one message at a time, so a failure retries only that message.
|
|
38
|
+
|
|
39
|
+
Fixes:
|
|
40
|
+
|
|
41
|
+
- Request-scoped services are destroyed when the request ends, also on failure, and an async `onInit` is awaited. A second dispose of a request scope waits for the one in progress, so a Worker's `waitUntil` covers an async `onDestroy`.
|
|
42
|
+
- A failed module or service `onInit` no longer breaks later requests: the boot is retried, and the module's event listeners are unwired.
|
|
43
|
+
- `@elysiumjs/runtime-node` serves through `node:http`, keeping the Host header authoritative.
|
|
44
|
+
- `styx create` validates names before creating files, ships `.gitignore`, declares the dependencies of created projects and imports their default controllers.
|
|
45
|
+
|
|
46
|
+
- [#14](https://github.com/na2axl/elysiumjs/pull/14) [`0956186`](https://github.com/na2axl/elysiumjs/commit/0956186db0ca5a83c0a6636aeb2a4c81a0033de4) Thanks [@na2axl](https://github.com/na2axl)! - The request pipeline: problem details, exception filters, interceptors, typed configuration, HTTP security options and an HTTP test client, checked on Bun, Node.js, workerd and AOT-compiled Workers.
|
|
47
|
+
|
|
48
|
+
Breaking changes:
|
|
49
|
+
|
|
50
|
+
- Error responses are RFC 9457 problem details (`application/problem+json`). Request validation errors answer 422 with an `errors` list of JSON pointers (`problems.validationStatus: 400` keeps 400). Server errors only show their message and stack in development. Thrown statuses (`c.status(...)`) are sent as they are.
|
|
51
|
+
- Client errors (4xx) are no longer reported to `onError`, the `elysium:error` event or the trace.
|
|
52
|
+
- `Runtime.serve` may return a handle with `close()`, which the application calls on shutdown.
|
|
53
|
+
- Request bodies are limited to 1 MiB by default (`http.bodyLimit`), also when chunked.
|
|
54
|
+
- The environment schema is validated when the app is built or run, with every error reported at once, instead of in the constructor.
|
|
55
|
+
|
|
56
|
+
Features:
|
|
57
|
+
|
|
58
|
+
- `DomainError`, with `status`, `type`, `title`, `detail` and extensions, and shortcuts such as `DomainError.notFound()`.
|
|
59
|
+
- Exception filters (`@Catch()`, `filters` on routes, controllers, modules and the app) and interceptors (`interceptors` on the same levels), with `TimingInterceptor`, `TransformInterceptor` and mnemosyne's `CacheInterceptor`.
|
|
60
|
+
- Typed configuration: `Secret` values, module `envSchema`s merged into the app's, `InvalidEnvironmentError`.
|
|
61
|
+
- `http` options: CORS, security headers, body limits and trusted proxies (`ctx.ip`). Runtimes report the client address through `clientAddress`.
|
|
62
|
+
- `createTestingHttpApp` and `createHttpClient` send requests to an app in memory, with `expectStatus()`.
|
|
63
|
+
|
|
64
|
+
Fixes:
|
|
65
|
+
|
|
66
|
+
- App-level `onRequest` middlewares run in the request scope.
|
|
67
|
+
- On Workers, the request scope of a streamed response is disposed when the body ends, not when `fetch` returns.
|
|
68
|
+
- `KeyvWorkersKV.clear()` stays within an operation budget (`maxOperations`).
|
|
69
|
+
- The Node.js bridge treats a destroyed socket as a client abort.
|
|
70
|
+
- Heracles performance metrics report through the Redis port (`connection` or `client`), not a Bun client.
|
|
71
|
+
- `styx routes:generate` names anonymous default controllers after their file; `styx create` pins `@elysiumjs/*` packages to the versions it was released with; invalid names are reported as Elysium errors.
|
|
72
|
+
|
|
73
|
+
- [#15](https://github.com/na2axl/elysiumjs/pull/15) [`fbcc7da`](https://github.com/na2axl/elysiumjs/commit/fbcc7dad127db29da42e7a4a5c2124966731cd88) Thanks [@na2axl](https://github.com/na2axl)! - Resources, queries, units of work and rate limits.
|
|
74
|
+
|
|
75
|
+
Breaking:
|
|
76
|
+
|
|
77
|
+
- A route with a `response` schema serializes its handler result through it: fields the schema does not declare are dropped, a `Date` becomes an ISO string (`YYYY-MM-DD` for `format: 'date'`), and an object with `toJSON()` becomes what it returns. Set `serialize: false` on a route to send the result as it is.
|
|
78
|
+
|
|
79
|
+
Added:
|
|
80
|
+
|
|
81
|
+
- `Resource.hidden(schema)` marks a field that never reaches responses nor the OpenAPI document. `Resource.serialize()` and `Resource.project()` are exported for use outside routes.
|
|
82
|
+
- Sparse fieldsets: `?fields=id,title` keeps only those fields of an object, of each item of an array, or of each item of a `data` array. An unknown or hidden field answers a 400 problem.
|
|
83
|
+
- Rate limits with `@RateLimit({ window, max, key?, store?, name? })` on routes and controllers (`@RateLimit(false)` turns one off), and a default limit and store in the app's `rateLimit` option, with fixed windows. Responses carry `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy`; a client over the limit gets a 429 problem with `Retry-After`. Counts are kept by a `RateLimitStore`: `MemoryRateLimitStore` (core), `RedisRateLimitStore` (`@elysiumjs/redis`) or `WorkersRateLimitStore` over a Cloudflare rate limiting binding (`@elysiumjs/runtime-workers`), which only decides whether a hit is over the limit, so its responses carry no `RateLimit-Reset`.
|
|
84
|
+
- `ModelResource(model, { hidden, pick, omit, extend })` (`@elysiumjs/mnemosyne`) builds a response schema from a model.
|
|
85
|
+
- `Query.define(model, spec)` declares the filters (`filter[field][op]=value`), sorts, page size and cursor a list accepts; `Query.schema()` gives its query schema, `Query.parse()` turns a request query into a `ParsedQuery` and answers a 400 problem with a pointer per mistake, and `Query.page()` / `Query.pageSchema()` build cursor pages (`{ data, nextCursor }`).
|
|
86
|
+
- `DrizzleRepository.list(query)` and `count(query)` run a `ParsedQuery` with keyset pagination (`@elysiumjs/mnemosyne-drizzle`), and `compileQuery()` gives its `where` and `orderBy`.
|
|
87
|
+
- Units of work: `UnitOfWork.run()` and the `@Transactional()` method decorator run work in one transaction, and `UnitOfWork.afterCommit()` / `afterRollback()` defer work, such as sending a message, until the outermost transaction has ended. `transactional` routes open a unit of work too.
|
|
88
|
+
|
|
89
|
+
- [#12](https://github.com/na2axl/elysiumjs/pull/12) [`98a16aa`](https://github.com/na2axl/elysiumjs/commit/98a16aae889d46f7a9fb5573188f4cbe3629dd3b) Thanks [@na2axl](https://github.com/na2axl)! - `createWorker(app)` from `@elysiumjs/runtime-workers` builds an application and returns the handlers a Cloudflare Worker exports: `fetch` serves the routes, `queue` hands each batch to the service methods marked `@Workers.queue(name)`, and `scheduled` calls the methods marked `@Workers.cron(expression)`. Each invocation runs with its own env and execution context: `Workers.env()`, `Workers.context()` and `Workers.binding(name)` read them, and `WorkersModule` provides them, and the bindings it lists, as request-scoped services injected with `@Workers.inject(name)`. `KeyvWorkersKV` stores mnemosyne caches in a KV namespace. `bun run check:wrangler` runs a sample Worker in `wrangler dev`.
|
|
90
|
+
|
|
91
|
+
`MnemosyneModule` takes other Keyv stores in `cache.stores`, read with `Cache.store(name)`.
|
|
92
|
+
|
|
93
|
+
`styx build --target workers` (Hephaestus `hephaestus:build --target workers`, `WorkersBuilder`) bundles a Worker with its routes compiled ahead of time and refuses a bundle that generates code at runtime.
|
|
94
|
+
|
|
95
|
+
In core, `Application.boot()` initialises the modules once, for hosts that handle more than requests; `Kernel.serviceClasses(node)` lists the service classes of a module; console output falls back to `console` where the host has no output streams; and a singleton depending on a request-scoped service throws `ScopeMismatchError` even when it is built outside a request.
|
|
96
|
+
|
|
97
|
+
### Patch Changes
|
|
98
|
+
|
|
99
|
+
- [#5](https://github.com/na2axl/elysiumjs/pull/5) [`557a3ca`](https://github.com/na2axl/elysiumjs/commit/557a3cab7af8053a8bab9944e492e5022d91aa82) Thanks [@na2axl](https://github.com/na2axl)! - Core runs outside Bun. Environment variables, timers, the HTTP server, process exit and shutdown signals now come from a `Runtime` set with the new `runtime` option of `@Application.register()` or with `Runtime.use()`. Runtimes ship as packages, each exporting `runtime`: `@elysiumjs/runtime-bun`, `@elysiumjs/runtime-node` and `@elysiumjs/runtime-workers` (which also exports `createRuntime({ env })`).
|
|
100
|
+
|
|
101
|
+
Breaking: an application needs a runtime (add `runtime` from `@elysiumjs/runtime-bun`), and `Redis` and `RedisModule` moved from core to the new `@elysiumjs/redis` package, which also accepts a `client` factory for runtimes without `Bun.RedisClient`.
|
|
102
|
+
|
|
103
|
+
Console colours are plain ANSI codes instead of `Bun.color`, `node:readline` is loaded only when a prompt runs, and core's dist is built for the `node` target so `node:` imports keep their prefix. `check:runtime` scripts in core and runtime-workers fail when their bundles touch the `Bun` global.
|
|
104
|
+
|
|
105
|
+
`create-elysium` gets a runtime step: it installs the chosen runtime package and sets it in the generated app. Bun is selectable today; Node.js and Cloudflare Workers are listed as coming later.
|
|
106
|
+
|
|
107
|
+
- [#20](https://github.com/na2axl/elysiumjs/pull/20) [`87299fd`](https://github.com/na2axl/elysiumjs/commit/87299fd9da82d1a408ced1891c0c3d42991444e5) Thanks [@na2axl](https://github.com/na2axl)! - The framework event bus is `Lifecycle`: `Lifecycle.on`, `Lifecycle.once`, `Lifecycle.emit`, `Lifecycle.off`, `@Lifecycle.on()` and `@Lifecycle.once()`, with the `LifecycleEvent` and `LifecycleHandler` types. It carries the framework's own signals (`elysium:error`, app and server lifecycle, heracles queue events) and in-process listeners.
|
|
108
|
+
- Updated dependencies [[`6bc48fe`](https://github.com/na2axl/elysiumjs/commit/6bc48febd51f7090c582e73f349eaeff7261241c), [`e9c3d98`](https://github.com/na2axl/elysiumjs/commit/e9c3d98dd2714b69100215f7c8a80046da463148), [`557a3ca`](https://github.com/na2axl/elysiumjs/commit/557a3cab7af8053a8bab9944e492e5022d91aa82), [`ccc3f5b`](https://github.com/na2axl/elysiumjs/commit/ccc3f5bb904d1bf52d8c72ebf7ea8d10f617a8c4), [`6789879`](https://github.com/na2axl/elysiumjs/commit/678987976ecfd46b534b65276e0de3f448977562), [`87299fd`](https://github.com/na2axl/elysiumjs/commit/87299fd9da82d1a408ced1891c0c3d42991444e5), [`bee66bd`](https://github.com/na2axl/elysiumjs/commit/bee66bdc874b8f23fea356f7d3adf7555a48ca64), [`0057b67`](https://github.com/na2axl/elysiumjs/commit/0057b67df0e3abecab8a9accff6b55a6a64c21df), [`632d77b`](https://github.com/na2axl/elysiumjs/commit/632d77b561fc43ade605fb69b06ac1b9b9a2ffc4), [`1ea417e`](https://github.com/na2axl/elysiumjs/commit/1ea417e3583250bc2f975d795f398b7ff736115d), [`0956186`](https://github.com/na2axl/elysiumjs/commit/0956186db0ca5a83c0a6636aeb2a4c81a0033de4), [`fbcc7da`](https://github.com/na2axl/elysiumjs/commit/fbcc7dad127db29da42e7a4a5c2124966731cd88), [`ac96e55`](https://github.com/na2axl/elysiumjs/commit/ac96e55874b0ffe350b287c4278cd85afecb7576), [`98a16aa`](https://github.com/na2axl/elysiumjs/commit/98a16aae889d46f7a9fb5573188f4cbe3629dd3b)]:
|
|
109
|
+
- @elysiumjs/core@0.11.0
|
|
110
|
+
- @elysiumjs/redis@0.1.0
|
|
111
|
+
|
|
112
|
+
## 0.3.1
|
|
113
|
+
|
|
114
|
+
### Patch Changes
|
|
115
|
+
|
|
116
|
+
- 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)]:
|
|
117
|
+
- @elysiumjs/core@0.10.0
|
|
118
|
+
|
|
119
|
+
## 0.3.0
|
|
120
|
+
|
|
121
|
+
### Minor Changes
|
|
122
|
+
|
|
123
|
+
- [`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.
|
|
124
|
+
|
|
125
|
+
Breaking changes:
|
|
126
|
+
|
|
127
|
+
- `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:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
class OrganizationMiddleware extends SimpleTenantMiddleware {
|
|
131
|
+
protected async validateTenant(tenant: string): Promise<boolean> {
|
|
132
|
+
return Organization.exists(tenant);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- `getTenantSchema()`, `registerTenantSchema()` and `wrapTenantSchema()` throw on tenant identifiers that do not match `TENANT_ID_PATTERN`.
|
|
138
|
+
- `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.
|
|
139
|
+
- `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.
|
|
140
|
+
- `@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.
|
|
141
|
+
- `@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.
|
|
142
|
+
- 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.
|
|
143
|
+
- 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.
|
|
144
|
+
- 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) => ...)`.
|
|
145
|
+
- `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.
|
|
146
|
+
- 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).
|
|
147
|
+
- `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.
|
|
148
|
+
- `Cache.memory` keeps a separate LRU per tag namespace, so `lruSize` applies to each namespace.
|
|
149
|
+
|
|
150
|
+
Fixes and additions:
|
|
151
|
+
|
|
152
|
+
- 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.
|
|
153
|
+
- `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.
|
|
154
|
+
- `@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`).
|
|
155
|
+
- 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.
|
|
156
|
+
- Cache namespaces (`Cache.redis.tags(...)`, `Cache.memory.tags(...)`) no longer overwrite each other: reads, deletes and `clear()` stay within their namespace.
|
|
157
|
+
- `getSessionTenant()` reads the Bun SQL result correctly, and `getCurrentTenant()` works without an application instance (for example under `Application.testing`).
|
|
158
|
+
- `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.
|
|
159
|
+
- `migration:generate` only removes `"public".` qualifiers from the migration it generates, and leaves string literals, comments and dollar-quoted bodies (`DO` blocks, functions) untouched.
|
|
160
|
+
- `DatabaseDriver.createConnection()` receives the connection name as an optional second argument.
|
|
161
|
+
- `KeyvRedis` deletes with a single `UNLINK` (or `DEL`) per call or batch and reports failed deletes in `clear()` as `error` events.
|
|
162
|
+
|
|
163
|
+
### Patch Changes
|
|
164
|
+
|
|
165
|
+
- Updated dependencies [[`5fef4a7`](https://github.com/na2axl/elysiumjs/commit/5fef4a7199f502fc4fe035299e7670739459a108)]:
|
|
166
|
+
- @elysiumjs/core@0.9.0
|
|
167
|
+
|
|
3
168
|
## 0.2.0
|
|
4
169
|
|
|
5
170
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -29,6 +29,18 @@ import { MnemosyneModule } from '@elysiumjs/mnemosyne';
|
|
|
29
29
|
export class App extends Application {}
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
Any Keyv store can back a cache of its own with `stores`, read with `Cache.store(name)`. On Cloudflare Workers, `KeyvWorkersKV` from `@elysiumjs/runtime-workers` stores entries in a KV namespace:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { KeyvWorkersKV } from '@elysiumjs/runtime-workers';
|
|
36
|
+
|
|
37
|
+
MnemosyneModule.with({
|
|
38
|
+
cache: { redis: false, stores: { kv: () => new KeyvWorkersKV({ binding: 'CACHE' }) } }
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await Cache.store('kv').set('greeting', 'hello', 120_000);
|
|
42
|
+
```
|
|
43
|
+
|
|
32
44
|
Models and repositories are built with a driver package. See [`@elysiumjs/mnemosyne-drizzle`](../mnemosyne-drizzle/README.md).
|
|
33
45
|
|
|
34
46
|
## License
|
package/dist/cache.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ import type { KeyvStoreAdapter } from 'keyv';
|
|
|
3
3
|
export declare const REDIS_CACHE_SERVICE = "mnemosyne.cache.redis";
|
|
4
4
|
/** Name of the global service backing `Cache.memory`. */
|
|
5
5
|
export declare const MEMORY_CACHE_SERVICE = "mnemosyne.cache.memory";
|
|
6
|
+
/**
|
|
7
|
+
* Name of the global service backing `Cache.store(name)`.
|
|
8
|
+
* @param name The store name, as given in the `MnemosyneModule` config.
|
|
9
|
+
*/
|
|
10
|
+
export declare const cacheStoreService: (name: string) => string;
|
|
6
11
|
/**
|
|
7
12
|
* Interface for a cache storage.
|
|
8
13
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
@@ -82,12 +87,16 @@ export interface CacheInterface {
|
|
|
82
87
|
tags(...tags: string[]): Omit<CacheInterface, 'tags'>;
|
|
83
88
|
}
|
|
84
89
|
/**
|
|
85
|
-
* Makes a cache interface
|
|
86
|
-
*
|
|
87
|
-
*
|
|
90
|
+
* Makes a cache interface over stores built by the given factory.
|
|
91
|
+
*
|
|
92
|
+
* Keyv writes its namespace on the store adapter it wraps, so adapters cannot be shared between
|
|
93
|
+
* namespaces: the last namespace would win for every Keyv using the adapter, and `clear()` would
|
|
94
|
+
* wipe the other namespaces. The base cache and each tagged cache therefore get their own adapter.
|
|
95
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
96
|
+
* @param createStore Creates a new store adapter, called once per namespace.
|
|
88
97
|
* @returns A new `CacheInterface`.
|
|
89
98
|
*/
|
|
90
|
-
export declare const makeCacheInterface: (
|
|
99
|
+
export declare const makeCacheInterface: (createStore: () => KeyvStoreAdapter) => CacheInterface;
|
|
91
100
|
/**
|
|
92
101
|
* Static access to the caches provided by the `MnemosyneModule`.
|
|
93
102
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
@@ -127,4 +136,12 @@ export declare abstract class Cache {
|
|
|
127
136
|
* @throws {ServiceNotFoundError} If the `MnemosyneModule` is not loaded or has no memory cache.
|
|
128
137
|
*/
|
|
129
138
|
static get memory(): CacheInterface;
|
|
139
|
+
/**
|
|
140
|
+
* A cache over a store given in `MnemosyneModule.with({ cache: { stores } })`, such as
|
|
141
|
+
* Workers KV.
|
|
142
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
143
|
+
* @param name The store name.
|
|
144
|
+
* @throws {ServiceNotFoundError} If the `MnemosyneModule` has no store with that name.
|
|
145
|
+
*/
|
|
146
|
+
static store(name: string): CacheInterface;
|
|
130
147
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { InterceptorClass, InterceptorContext } from '@elysiumjs/core';
|
|
2
|
+
/**
|
|
3
|
+
* Options of {@link CacheInterceptor.with}.
|
|
4
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
5
|
+
*/
|
|
6
|
+
export type CacheInterceptorOptions = {
|
|
7
|
+
/** How long a response stays cached, in milliseconds. */
|
|
8
|
+
ttl: number;
|
|
9
|
+
/**
|
|
10
|
+
* The cache: `memory` (the default), `redis`, or the name of a store given in
|
|
11
|
+
* `MnemosyneModule.with({ cache: { stores } })`.
|
|
12
|
+
*/
|
|
13
|
+
store?: 'memory' | 'redis' | (string & {});
|
|
14
|
+
/**
|
|
15
|
+
* Builds the cache key of a request. Defaults to the tenant, the method, the path and the query
|
|
16
|
+
* string, so that tenants never share entries.
|
|
17
|
+
*/
|
|
18
|
+
key?: (ctx: InterceptorContext) => string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Builds interceptors that cache handler results.
|
|
22
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
23
|
+
*/
|
|
24
|
+
export declare abstract class CacheInterceptor {
|
|
25
|
+
/**
|
|
26
|
+
* Creates an interceptor class that caches the results of `GET` and `HEAD` handlers. A cached
|
|
27
|
+
* result skips the handler. Responses (`Response` objects) are never cached. The response gets
|
|
28
|
+
* an `x-cache: hit` or `x-cache: miss` header.
|
|
29
|
+
* @param options The TTL, the cache and the key.
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* @Http.get('/trending', { interceptors: [CacheInterceptor.with({ ttl: 30_000 })] })
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
static with(options: CacheInterceptorOptions): InterceptorClass;
|
|
36
|
+
}
|
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.d.ts
CHANGED
|
@@ -2,8 +2,12 @@ export type { ColumnMetadata, DatabaseCacheConfig, DatabaseCacheStrategy, Databa
|
|
|
2
2
|
export * from './database';
|
|
3
3
|
export * from './model';
|
|
4
4
|
export * from './repository';
|
|
5
|
+
export { ModelResource, type ModelResourceOptions } from './resource';
|
|
6
|
+
export { type TransactionRunner, Transactional, UnitOfWork, withUnitOfWork } from './unit-of-work';
|
|
7
|
+
export { type CursorPage, type FilterOperator, type ParsedFilter, type ParsedQuery, type ParsedSort, type QueryFieldType, type QueryFilterSpec, type QuerySpec, Query } from './query';
|
|
5
8
|
export * from './tenancy';
|
|
6
9
|
export type { TenancyConfig, TenancyMode, ModelTenancyConfig } from './tenancy';
|
|
7
|
-
export { Cache, type CacheInterface, MEMORY_CACHE_SERVICE, REDIS_CACHE_SERVICE } from './cache';
|
|
10
|
+
export { Cache, type CacheInterface, cacheStoreService, MEMORY_CACHE_SERVICE, REDIS_CACHE_SERVICE } from './cache';
|
|
8
11
|
export * from './mnemosyne.module';
|
|
9
12
|
export * from './keyv-redis';
|
|
13
|
+
export { CacheInterceptor, type CacheInterceptorOptions } from './cache.interceptor';
|
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",cacheStoreService=(name)=>`mnemosyne.cache.store.${name}`,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)}static store(name){return getCache(cacheStoreService(name))}}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.Codec(t.String({format:"date"})).Decode((date)=>new Date(date)).Encode((date)=>date.toISOString());case"datetime":return t.Codec(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{Resource}from"@elysiumjs/core";import{t as t2}from"elysia";var ModelResource=(model,options={})=>{let select=model.selectSchema,hidden=new Set(options.hidden??[]),omit=new Set(options.omit??[]),pick=options.pick?new Set(options.pick):void 0,properties={};for(let[name,schema]of Object.entries(select.properties)){if(omit.has(name)||pick&&!pick.has(name)&&!hidden.has(name))continue;properties[name]=hidden.has(name)?Resource.hidden(schema):schema}return t2.Object({...properties,...options.extend})};import{Application as Application4,DATABASE_TRANSACTION_SERVICE as DATABASE_TRANSACTION_SERVICE2,DomainError,getLogger,Service as Service4}from"@elysiumjs/core";var FRAME="uow:frame";class Refusal{error;constructor(error){this.error=error}}var currentFrame=()=>Application4.context.getStore()?.get(FRAME),runHooks=async(hooks,phase)=>{for(let hook of hooks)try{await hook()}catch(error){getLogger().error(`A unit of work ${phase} hook failed`,{error})}},withUnitOfWork=(transaction)=>async(callback,connection)=>{if(currentFrame())return transaction(callback,connection);let frame={afterCommit:[],afterRollback:[]},store=new Map(Application4.context.getStore());store.set(FRAME,frame);let result;try{result=await Application4.context.run(store,()=>transaction(async()=>{let value=await callback();if(value instanceof DomainError)throw new Refusal(value);return value},connection))}catch(error){if(await runHooks(frame.afterRollback,"afterRollback"),error instanceof Refusal)return error.error;throw error}return await runHooks(frame.afterCommit,"afterCommit"),result},UnitOfWork;((UnitOfWork)=>(UnitOfWork.run=(callback,options={})=>{let runner=Service4.get(DATABASE_TRANSACTION_SERVICE2);if(!runner)throw Error(`Units of work need the "${DATABASE_TRANSACTION_SERVICE2}" service. Import a database module (such as DrizzleModule) in the application.`);return runner(callback,options.connection)},UnitOfWork.active=()=>currentFrame()!==void 0,UnitOfWork.afterCommit=async(hook)=>{let frame=currentFrame();if(frame)frame.afterCommit.push(hook);else await runHooks([hook],"afterCommit")},UnitOfWork.afterRollback=(hook)=>{currentFrame()?.afterRollback.push(hook)}))(UnitOfWork||={});var Transactional=(options={})=>(_target,_key,descriptor)=>{let method=descriptor.value;return descriptor.value=function(...args){return UnitOfWork.run(()=>method.apply(this,args),options)},descriptor};import{BadRequestError}from"@elysiumjs/core";import{t as t3}from"elysia";var DEFAULT_LIMIT=20,DEFAULT_MAX_LIMIT=100,FILTER_KEY=/^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/,toBase64Url=(text)=>btoa(String.fromCharCode(...new TextEncoder().encode(text))).replaceAll("+","-").replaceAll("/","_").replace(/=+$/,""),fromBase64Url=(text)=>{let base64=text.replaceAll("-","+").replaceAll("_","/"),binary=atob(base64+"=".repeat((4-base64.length%4)%4));return new TextDecoder().decode(Uint8Array.from(binary,(char)=>char.charCodeAt(0)))},UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,readValue=(type,raw)=>{switch(type){case"number":{let value=Number(raw);return raw.trim()!==""&&Number.isFinite(value)?{value}:{error:"must be a number"}}case"boolean":return raw==="true"||raw==="false"?{value:raw==="true"}:{error:"must be true or false"};case"date":{let value=new Date(raw);return Number.isNaN(value.getTime())?{error:"must be a date"}:{value}}case"uuid":return UUID.test(raw)?{value:raw}:{error:"must be a UUID"};default:return{value:raw}}},typeOfSchema=(schema)=>{let node=schema.anyOf?.find((branch)=>branch.type!=="null")??schema,{type,format}=node;if(type==="number"||type==="integer"||type==="bigint")return"number";if(type==="boolean")return"boolean";if(format==="date"||format==="date-time")return"date";if(format==="uuid")return"uuid";return"string"},normalizeFilter=(spec)=>Array.isArray(spec)?{operators:spec}:spec,Query;((Query)=>{Query.define=(model,spec)=>{let properties=model.selectSchema.properties??{},types={};for(let[name,schema]of Object.entries(properties))types[name]=typeOfSchema(schema);return{...spec,types}};let typeOf=(spec,field,column)=>{let filter=spec.filters?.[field];return(filter&&normalizeFilter(filter).type)??spec.types?.[column]??spec.types?.[field]??"string"},columnOf=(spec,field)=>{let filter=spec.filters?.[field];return(filter&&normalizeFilter(filter).column)??spec.columns?.[field]??field};Query.schema=(spec)=>{let properties={};for(let[field,raw]of Object.entries(spec.filters??{})){let filter=normalizeFilter(raw);properties[`filter[${field}]`]=t3.Optional(t3.String({description:`Filters ${field} (${filter.operators[0]}).`}));for(let operator of filter.operators)properties[`filter[${field}][${operator}]`]=t3.Optional(t3.String({description:`Filters ${field} with ${operator}.`}))}if(spec.sort?.length)properties.sort=t3.Optional(t3.String({description:`Comma-separated sort fields, "-" for descending: ${spec.sort.join(", ")}.`}));return properties.cursor=t3.Optional(t3.String({description:"The cursor of the next page."})),properties.limit=t3.Optional(t3.String({description:`Rows per page, up to ${spec.limit?.max??DEFAULT_MAX_LIMIT}.`})),properties.fields=t3.Optional(t3.String({description:"Comma-separated fields to return."})),t3.Object(properties,{additionalProperties:!0})},Query.parse=(spec,query={})=>{let errors=[],fail=(parameter,message,code)=>errors.push({pointer:`/query/${parameter.replaceAll("/","~1")}`,message,code}),filters=[];for(let[key,raw]of Object.entries(query)){let match=FILTER_KEY.exec(key);if(!match)continue;let[,field,named]=match,filterSpec=spec.filters?.[field];if(!filterSpec){fail(key,`cannot filter by "${field}"`,"filter");continue}let{operators}=normalizeFilter(filterSpec),operator=named??operators[0];if(!operators.includes(operator)){fail(key,`"${operator}" is not allowed on "${field}"`,"operator");continue}let column=columnOf(spec,field),type=typeOf(spec,field,column),text=String(Array.isArray(raw)?raw.at(-1):raw);if(operator==="isNull"){let read=readValue("boolean",text);if("error"in read)fail(key,read.error,"type");else filters.push({field,column,operator,value:read.value});continue}if(operator==="in"){let values=[];for(let part of text.split(",")){let read=readValue(type,part.trim());if("error"in read)fail(key,`each value ${read.error}`,"type");else values.push(read.value)}if(values.length>0&&values.length===text.split(",").length)filters.push({field,column,operator,value:values});continue}let read=readValue(operator==="contains"||operator==="startsWith"?"string":type,text);if("error"in read)fail(key,read.error,"type");else filters.push({field,column,operator,value:read.value})}let key=spec.key??"id",sort=[],rawSort=typeof query.sort==="string"&&query.sort!==""?query.sort:spec.defaultSort;for(let part of(rawSort??"").split(",").map((item)=>item.trim()).filter(Boolean)){let direction=part.startsWith("-")?"desc":"asc",field=part.replace(/^[-+]/,"");if(!spec.sort?.includes(field)){fail("sort",`cannot sort by "${field}"`,"sort");continue}sort.push({field,column:columnOf(spec,field),direction})}if(!sort.some((item)=>item.column===key))sort.push({field:key,column:key,direction:sort.at(-1)?.direction??"asc"});let max=spec.limit?.max??DEFAULT_MAX_LIMIT,limit=spec.limit?.default??DEFAULT_LIMIT;if(query.limit!==void 0&&query.limit!==""){let value=Number(query.limit);if(!Number.isInteger(value)||value<1||value>max)fail("limit",`must be a whole number from 1 to ${max}`,"limit");else limit=value}let after;if(typeof query.cursor==="string"&&query.cursor!==""){let values=decodeCursor(query.cursor,sort.length)?.map((value,index)=>{if(value===null)return{value:null};if(!["string","number","boolean"].includes(typeof value))return{error:"bad"};let{field,column}=sort[index];return readValue(typeOf(spec,field,column),String(value))});if(!values||values.some((read)=>("error"in read)))fail("cursor","is not a cursor of this listing","cursor");else after=values.map((read)=>read.value)}if(errors.length>0)throw new BadRequestError("The query is invalid.",{errors});return{filters,sort,after,limit}},Query.cursorAfter=(query,row)=>toBase64Url(JSON.stringify(query.sort.map(({column})=>{let value=row[column];return value instanceof Date?value.toISOString():value})));let decodeCursor=(cursor,length)=>{try{let values=JSON.parse(fromBase64Url(cursor));return Array.isArray(values)&&values.length===length?values:void 0}catch{return}};Query.page=(query,rows)=>{let data=rows.slice(0,query.limit),more=rows.length>query.limit;return{data,nextCursor:more?Query.cursorAfter(query,data.at(-1)):null}},Query.pageSchema=(item)=>t3.Object({data:t3.Array(item),nextCursor:t3.Nullable(t3.String())})})(Query||={});import{Module}from"@elysiumjs/core";import{KeyvCacheableMemory}from"cacheable";import{EventEmitter}from"events";import{Lifecycle,Service as Service5}from"@elysiumjs/core";import{Redis}from"@elysiumjs/redis";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)=>{Lifecycle.emit("elysium:error",error)})}async getClient(){return this.#client??=await Service5.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.send("SET",[key,value,"PX",String(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.send("SET",[prefixedKey,value,"PX",String(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),Number(await client.send("EXISTS",[key]))>0}async hasMany(keys){let client=await this.getClient(),multi=[];for(let key of keys){let prefixedKey=this.createKeyPrefix(key,this.#namespace);multi.push(client.send("EXISTS",[prefixedKey]).then((count)=>Number(count)>0))}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.send("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.send("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.send("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,stores}=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}))});for(let[name,createStore]of Object.entries(stores??{}))services.push({name:cacheStoreService(name),global:!0,factory:()=>makeCacheInterface(createStore)});return services}})],MnemosyneModule);var resolveStore=(store)=>store==="memory"?Cache.memory:store==="redis"?Cache.redis:Cache.store(store),defaultKey=(ctx)=>{let url=new URL(ctx.request.url);return`http:${typeof ctx.tenant==="string"?ctx.tenant:"public"}:${ctx.request.method}:${url.pathname}${url.search}`};class CacheInterceptor{static with(options){let{ttl,store="memory",key=defaultKey}=options;return class{async intercept(ctx,next){let method=ctx.request.method;if(method!=="GET"&&method!=="HEAD")return next();let cache=resolveStore(store),cacheKey=key(ctx),cached=await cache.get(cacheKey);if(cached!==void 0)return ctx.set.headers["x-cache"]="hit",cached.value;let value=await next();if(ctx.set.headers["x-cache"]="miss",!(value instanceof Response))await cache.set(cacheKey,{value},ttl);return value}}}}export{AbstractDatabase,AbstractModel,AbstractRepository,Cache,CacheInterceptor,DATABASE_TRANSACTION_SERVICE,KeyvRedis,MEMORY_CACHE_SERVICE,MnemosyneModule,ModelResource,Query,REDIS_CACHE_SERVICE,SimpleTenantMiddleware,StrictTenantMiddleware,TENANCY_CONFIG_SERVICE,TENANCY_STRATEGY_SERVICE,TENANT_ID_PATTERN,TenantMiddleware,Transactional,UnitOfWork,cacheStoreService,createDatabaseCacheStorage,createSchemaFromModel,getCurrentTenant,getTenancyConfig,getTenancyStrategy,isValidTenantId,withTenant,withUnitOfWork};
|
package/dist/interfaces.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TSchema } from '
|
|
1
|
+
import type { TSchema } from '@elysiumjs/core';
|
|
2
2
|
/**
|
|
3
3
|
* Contract for ORM drivers.
|
|
4
4
|
*
|
|
@@ -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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RedisConnection } from '@elysiumjs/redis';
|
|
1
2
|
import type { KeyvStoreAdapter } from 'cacheable';
|
|
2
3
|
import type { KeyvEntry, StoredData } from 'keyv';
|
|
3
4
|
import { EventEmitter } from 'node:events';
|
|
@@ -25,7 +26,7 @@ export declare class KeyvRedis extends EventEmitter implements KeyvStoreAdapter
|
|
|
25
26
|
*/
|
|
26
27
|
clearBatchSize?: number;
|
|
27
28
|
/**
|
|
28
|
-
*
|
|
29
|
+
* Use UNLINK instead of DEL to delete keys. UNLINK frees memory in the background (Redis 4+).
|
|
29
30
|
*/
|
|
30
31
|
useUnlink?: boolean;
|
|
31
32
|
/**
|
|
@@ -39,7 +40,7 @@ export declare class KeyvRedis extends EventEmitter implements KeyvStoreAdapter
|
|
|
39
40
|
* Resolves the Redis client on first use. The async path is always safe, even when the
|
|
40
41
|
* Redis module has not been initialised yet (lazy boot).
|
|
41
42
|
*/
|
|
42
|
-
protected getClient(): Promise<
|
|
43
|
+
protected getClient(): Promise<RedisConnection>;
|
|
43
44
|
/**
|
|
44
45
|
* Get the options for the adapter.
|
|
45
46
|
*/
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { KeyvStoreAdapter } from 'keyv';
|
|
1
2
|
/**
|
|
2
3
|
* Configuration of the {@link MnemosyneModule}.
|
|
3
4
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
@@ -16,13 +17,18 @@ export type MnemosyneModuleConfig = {
|
|
|
16
17
|
/** The maximum number of entries kept. */
|
|
17
18
|
lruSize: number;
|
|
18
19
|
} | false;
|
|
20
|
+
/**
|
|
21
|
+
* Other caches, each over the Keyv stores its function creates, read with
|
|
22
|
+
* `Cache.store(name)`. The function is called once per cache namespace.
|
|
23
|
+
*/
|
|
24
|
+
stores?: Record<string, () => KeyvStoreAdapter>;
|
|
19
25
|
};
|
|
20
26
|
};
|
|
21
27
|
declare const MnemosyneModule_base: (abstract new () => {
|
|
22
28
|
readonly config: MnemosyneModuleConfig;
|
|
23
29
|
onInit(): Promise<void> | void;
|
|
24
30
|
onDestroy(): Promise<void> | void;
|
|
25
|
-
onServe(_app: import("elysia").
|
|
31
|
+
onServe(_app: import("elysia").Elysia<any>): Promise<void> | void;
|
|
26
32
|
}) & {
|
|
27
33
|
readonly config: string;
|
|
28
34
|
with(config: Partial<MnemosyneModuleConfig> | (() => Partial<MnemosyneModuleConfig> | Promise<Partial<MnemosyneModuleConfig>>)): import("@elysiumjs/core").ConfiguredModule<MnemosyneModuleConfig>;
|
|
@@ -34,8 +40,8 @@ declare const MnemosyneModule_base: (abstract new () => {
|
|
|
34
40
|
}): any;
|
|
35
41
|
};
|
|
36
42
|
/**
|
|
37
|
-
* Provides the caches as the global services `mnemosyne.cache.redis
|
|
38
|
-
* `mnemosyne.cache.memory
|
|
43
|
+
* Provides the caches as the global services `mnemosyne.cache.redis`,
|
|
44
|
+
* `mnemosyne.cache.memory` and `mnemosyne.cache.store.<name>`. Tenancy config and strategy, and database connections, are
|
|
39
45
|
* registered by the database driver modules.
|
|
40
46
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
41
47
|
*/
|