@elysiumjs/mnemosyne 0.3.1 → 0.5.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 CHANGED
@@ -1,5 +1,126 @@
1
1
  # @elysiumjs/mnemosyne
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#26](https://github.com/na2axl/elysiumjs/pull/26) [`4cd1ef2`](https://github.com/na2axl/elysiumjs/commit/4cd1ef217826164e69cd641ca99f74c6f5a7ebe7) Thanks [@na2axl](https://github.com/na2axl)! - Type the query string of the query DSL. `Query.define` keeps the names of the filters, their operators and the sortable fields, `Query.Params<typeof spec>` types the parameter that `@Http.query()` decorates (`filter[status]`, `filter[status][in]`, `sort`, `cursor`, `limit`, `fields`, each an optional string), and `Query.schema(spec)` returns a schema of that type. Because the generated routes read the parameter's type, clients get typed query keys. Core re-exports TypeBox's `TOptional` and `TString` types.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [[`4cd1ef2`](https://github.com/na2axl/elysiumjs/commit/4cd1ef217826164e69cd641ca99f74c6f5a7ebe7)]:
12
+ - @elysiumjs/core@0.11.1
13
+ - @elysiumjs/redis@0.1.0
14
+
15
+ ## 0.4.0
16
+
17
+ ### Minor Changes
18
+
19
+ - [#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`.
20
+
21
+ Breaking:
22
+
23
+ - `TSchema` and `Static` are re-exported from `typebox` instead of `@sinclair/typebox`; `Value` comes from `typebox/value` and `Value.Errors()` returns an array.
24
+ - `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).
25
+ - `AppHttpContext` is a plain interface with `decorator`, `store` and `derive` records (Elysia 2 renamed `resolve` to `derive`).
26
+ - Websocket controllers install Elysia's `websocket()` capability themselves.
27
+
28
+ 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`.
29
+
30
+ `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.
31
+
32
+ - [#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.
33
+
34
+ - `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.
35
+ - Ready-made errors: `BadRequestError`, `ValidationFailedError`, `UnauthenticatedError`, `ForbiddenError`, `NotFoundError` and `ConflictError`.
36
+ - `ProblemCatalogue`, extended through declaration merging, lists the codes of an application; once it has keys, `DomainError.for()` takes only those and `ELYSIUM_PROBLEM_CODES`.
37
+ - 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`.
38
+ - 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.
39
+
40
+ - [#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.
41
+
42
+ Breaking changes:
43
+
44
+ - `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.
45
+ - The third parameter of `InteractsWithConsole.write` is now `'stdout' | 'stderr'`.
46
+ - The WebSocket options are narrowed to `idleTimeout`, `maxPayloadLength` and `perMessageDeflate`, and the `bun` WebSocket types are replaced by `ElysiumWebSocket`.
47
+ - Redis connections are `RedisConnection`s, a port any Redis client can implement, instead of `Bun.RedisClient`.
48
+ - `styx create @scope/name` creates the project in `name/`, and refuses project and module names with upper-case letters.
49
+ - 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.
50
+
51
+ Fixes:
52
+
53
+ - 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`.
54
+ - A failed module or service `onInit` no longer breaks later requests: the boot is retried, and the module's event listeners are unwired.
55
+ - `@elysiumjs/runtime-node` serves through `node:http`, keeping the Host header authoritative.
56
+ - `styx create` validates names before creating files, ships `.gitignore`, declares the dependencies of created projects and imports their default controllers.
57
+
58
+ - [#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.
59
+
60
+ Breaking changes:
61
+
62
+ - 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.
63
+ - Client errors (4xx) are no longer reported to `onError`, the `elysium:error` event or the trace.
64
+ - `Runtime.serve` may return a handle with `close()`, which the application calls on shutdown.
65
+ - Request bodies are limited to 1 MiB by default (`http.bodyLimit`), also when chunked.
66
+ - The environment schema is validated when the app is built or run, with every error reported at once, instead of in the constructor.
67
+
68
+ Features:
69
+
70
+ - `DomainError`, with `status`, `type`, `title`, `detail` and extensions, and shortcuts such as `DomainError.notFound()`.
71
+ - 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`.
72
+ - Typed configuration: `Secret` values, module `envSchema`s merged into the app's, `InvalidEnvironmentError`.
73
+ - `http` options: CORS, security headers, body limits and trusted proxies (`ctx.ip`). Runtimes report the client address through `clientAddress`.
74
+ - `createTestingHttpApp` and `createHttpClient` send requests to an app in memory, with `expectStatus()`.
75
+
76
+ Fixes:
77
+
78
+ - App-level `onRequest` middlewares run in the request scope.
79
+ - On Workers, the request scope of a streamed response is disposed when the body ends, not when `fetch` returns.
80
+ - `KeyvWorkersKV.clear()` stays within an operation budget (`maxOperations`).
81
+ - The Node.js bridge treats a destroyed socket as a client abort.
82
+ - Heracles performance metrics report through the Redis port (`connection` or `client`), not a Bun client.
83
+ - `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.
84
+
85
+ - [#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.
86
+
87
+ Breaking:
88
+
89
+ - 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.
90
+
91
+ Added:
92
+
93
+ - `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.
94
+ - 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.
95
+ - 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`.
96
+ - `ModelResource(model, { hidden, pick, omit, extend })` (`@elysiumjs/mnemosyne`) builds a response schema from a model.
97
+ - `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 }`).
98
+ - `DrizzleRepository.list(query)` and `count(query)` run a `ParsedQuery` with keyset pagination (`@elysiumjs/mnemosyne-drizzle`), and `compileQuery()` gives its `where` and `orderBy`.
99
+ - 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.
100
+
101
+ - [#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`.
102
+
103
+ `MnemosyneModule` takes other Keyv stores in `cache.stores`, read with `Cache.store(name)`.
104
+
105
+ `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.
106
+
107
+ 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.
108
+
109
+ ### Patch Changes
110
+
111
+ - [#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 })`).
112
+
113
+ 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`.
114
+
115
+ 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.
116
+
117
+ `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.
118
+
119
+ - [#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.
120
+ - 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)]:
121
+ - @elysiumjs/core@0.11.0
122
+ - @elysiumjs/redis@0.1.0
123
+
3
124
  ## 0.3.1
4
125
 
5
126
  ### Patch 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>
@@ -131,4 +136,12 @@ export declare abstract class Cache {
131
136
  * @throws {ServiceNotFoundError} If the `MnemosyneModule` is not loaded or has no memory cache.
132
137
  */
133
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;
134
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/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 QueryFilters, type QueryParamKeys, type QueryParams, type QuerySchemaProperties, 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=(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};
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)=>("operators"in spec)?spec:{operators: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};
@@ -1,4 +1,4 @@
1
- import type { TSchema } from 'elysia';
1
+ import type { TSchema } from '@elysiumjs/core';
2
2
  /**
3
3
  * Contract for ORM drivers.
4
4
  *
@@ -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';
@@ -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<Bun.RedisClient>;
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").default<any>): Promise<void> | void;
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` and
38
- * `mnemosyne.cache.memory`. Tenancy config and strategy, and database connections, are
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
  */
package/dist/model.d.ts CHANGED
@@ -8,7 +8,7 @@ import type { ColumnMetadata, ModelAdapter } from './interfaces';
8
8
  */
9
9
  export declare const createSchemaFromModel: (columns: ColumnMetadata[], { mode }?: {
10
10
  mode?: 'create' | 'update' | 'select';
11
- }) => import("@sinclair/typebox").TObject<{}>;
11
+ }) => import("typebox").TObject<{}>;
12
12
  /**
13
13
  * Mixin used to create a new abstract model class.
14
14
  * @author Axel Nana <axel.nana@workbud.com>
@@ -49,15 +49,15 @@ export declare const AbstractModel: <TTable, TColumns extends Record<string, unk
49
49
  /**
50
50
  * The validation schema for creating records.
51
51
  */
52
- readonly insertSchema: import("@sinclair/typebox").TObject<{}>;
52
+ readonly insertSchema: import("typebox").TObject<{}>;
53
53
  /**
54
54
  * The validation schema for updating records.
55
55
  */
56
- readonly updateSchema: import("@sinclair/typebox").TObject<{}>;
56
+ readonly updateSchema: import("typebox").TObject<{}>;
57
57
  /**
58
58
  * The validation schema for selecting records.
59
59
  */
60
- readonly selectSchema: import("@sinclair/typebox").TObject<{}>;
60
+ readonly selectSchema: import("typebox").TObject<{}>;
61
61
  /**
62
62
  * Whether the model supports tenancy.
63
63
  *
@@ -0,0 +1,199 @@
1
+ import type { TObject, TOptional, TSchema, TString } from '@elysiumjs/core';
2
+ import type { ModelClass } from './interfaces';
3
+ /**
4
+ * A filter operator of the query DSL.
5
+ *
6
+ * - `eq`, `ne`, `gt`, `gte`, `lt`, `lte`: comparisons.
7
+ * - `in`: one of a comma-separated list.
8
+ * - `contains`, `startsWith`: case-insensitive text matches.
9
+ * - `isNull`: `true` or `false`.
10
+ * @author Axel Nana <axel.nana@workbud.com>
11
+ */
12
+ export type FilterOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'contains' | 'startsWith' | 'isNull';
13
+ /**
14
+ * The type of a filtered or sorted field, which decides how its query values are read.
15
+ * @author Axel Nana <axel.nana@workbud.com>
16
+ */
17
+ export type QueryFieldType = 'string' | 'number' | 'boolean' | 'date' | 'uuid';
18
+ /**
19
+ * A filterable field.
20
+ * @author Axel Nana <axel.nana@workbud.com>
21
+ */
22
+ export type QueryFilterSpec<TOperator extends FilterOperator = FilterOperator> = {
23
+ /** The operators the field accepts. The first one is used by `filter[field]=value`. */
24
+ operators: readonly TOperator[];
25
+ /** The type of the values. Read from the model by {@link Query.define}; `string` otherwise. */
26
+ type?: QueryFieldType;
27
+ /** The model property behind the field, when it has another name. */
28
+ column?: string;
29
+ };
30
+ /**
31
+ * The filterable fields of a spec, each with its operators or its {@link QueryFilterSpec}.
32
+ * @author Axel Nana <axel.nana@workbud.com>
33
+ */
34
+ export type QueryFilters = Record<string, QueryFilterSpec | readonly FilterOperator[]>;
35
+ /**
36
+ * What clients may filter and sort by, and how many rows a page holds.
37
+ *
38
+ * The type parameters keep the names of the filters and the sortable fields, so that
39
+ * {@link Query.Params} and {@link Query.schema} can type the query string.
40
+ * @author Axel Nana <axel.nana@workbud.com>
41
+ */
42
+ export type QuerySpec<TFilters extends QueryFilters = QueryFilters, TSort extends string = string> = {
43
+ /** The filterable fields, with their operators. */
44
+ filters?: TFilters;
45
+ /** The sortable fields. Sortable columns should not be nullable, for cursor pagination. */
46
+ sort?: readonly TSort[];
47
+ /** The sort used when the query sets none, such as `-createdAt`. */
48
+ defaultSort?: string;
49
+ /** The page size: the default, and the largest a client may ask for. */
50
+ limit?: {
51
+ default?: number;
52
+ max?: number;
53
+ };
54
+ /** The model property that identifies rows, the tiebreaker of every sort. Defaults to `id`. */
55
+ key?: string;
56
+ /** The types of the model's properties, set by {@link Query.define}. */
57
+ types?: Record<string, QueryFieldType>;
58
+ /** The property names of the model, for sortable fields that map to another column. */
59
+ columns?: Record<string, string>;
60
+ };
61
+ /**
62
+ * One filter of a parsed query.
63
+ * @author Axel Nana <axel.nana@workbud.com>
64
+ */
65
+ export type ParsedFilter = {
66
+ field: string;
67
+ column: string;
68
+ operator: FilterOperator;
69
+ value: unknown;
70
+ };
71
+ /**
72
+ * One sort key of a parsed query.
73
+ * @author Axel Nana <axel.nana@workbud.com>
74
+ */
75
+ export type ParsedSort = {
76
+ field: string;
77
+ column: string;
78
+ direction: 'asc' | 'desc';
79
+ };
80
+ /**
81
+ * A query string, validated against a {@link QuerySpec}, that repositories run.
82
+ * @author Axel Nana <axel.nana@workbud.com>
83
+ */
84
+ export type ParsedQuery = {
85
+ filters: ParsedFilter[];
86
+ /** The sort keys, ending with the row key as the tiebreaker. */
87
+ sort: ParsedSort[];
88
+ /** The values of the sort keys of the last row of the previous page. */
89
+ after?: unknown[];
90
+ limit: number;
91
+ };
92
+ /**
93
+ * A page of rows from cursor pagination.
94
+ * @author Axel Nana <axel.nana@workbud.com>
95
+ */
96
+ export type CursorPage<T> = {
97
+ data: T[];
98
+ /** The cursor of the next page, or `null` on the last page. */
99
+ nextCursor: string | null;
100
+ };
101
+ /** The operators of one filter of a spec. */
102
+ type OperatorsOf<TFilter> = TFilter extends readonly (infer TOperator)[] ? TOperator : TFilter extends {
103
+ operators: readonly (infer TOperator)[];
104
+ } ? TOperator : never;
105
+ /** The filter keys of a spec: `filter[field]` and `filter[field][operator]`. */
106
+ type FilterKeysOf<TFilters> = {
107
+ [K in keyof TFilters & string]: `filter[${K}]` | `filter[${K}][${OperatorsOf<TFilters[K]> & string}]`;
108
+ }[keyof TFilters & string];
109
+ /**
110
+ * The parameter names of the query string of a spec.
111
+ * @author Axel Nana <axel.nana@workbud.com>
112
+ */
113
+ export type QueryParamKeys<TSpec> = TSpec extends QuerySpec<infer TFilters, infer TSort> ? FilterKeysOf<TFilters> | ([TSort] extends [never] ? never : 'sort') | 'cursor' | 'limit' | 'fields' : never;
114
+ /**
115
+ * The query string of a spec, as Elysia parses it: every parameter is an optional string.
116
+ * @author Axel Nana <axel.nana@workbud.com>
117
+ */
118
+ export type QueryParams<TSpec> = {
119
+ [K in QueryParamKeys<TSpec>]?: string;
120
+ };
121
+ /**
122
+ * The properties of the query schema of a spec.
123
+ * @author Axel Nana <axel.nana@workbud.com>
124
+ */
125
+ export type QuerySchemaProperties<TSpec> = {
126
+ [K in QueryParamKeys<TSpec>]: TOptional<TString>;
127
+ };
128
+ export declare namespace Query {
129
+ /**
130
+ * Builds a query spec for a model: the types of the filtered and sorted fields are read from
131
+ * the model's select schema.
132
+ * @author Axel Nana <axel.nana@workbud.com>
133
+ * @param model The model class.
134
+ * @param spec The filters, sorts and page size.
135
+ * @example
136
+ * ```ts
137
+ * const PostQuery = Query.define(Post, {
138
+ * filters: { status: ['eq', 'in'], createdAt: ['gte', 'lte'], title: ['contains'] },
139
+ * sort: ['createdAt', 'title'],
140
+ * defaultSort: '-createdAt'
141
+ * });
142
+ * ```
143
+ */
144
+ const define: <TModel extends ModelClass, const TFilters extends QueryFilters = {}, const TSort extends string = never>(model: TModel, spec: Omit<QuerySpec<TFilters, TSort>, 'types'>) => QuerySpec<TFilters, TSort>;
145
+ /**
146
+ * The query string of a spec, for the parameter that `@Http.query()` decorates.
147
+ * @author Axel Nana <axel.nana@workbud.com>
148
+ * @example
149
+ * ```ts
150
+ * @Http.get('/posts')
151
+ * list(@Http.query(Query.schema(PostQuery)) query: Query.Params<typeof PostQuery>) {
152
+ * return this.posts.list(Query.parse(PostQuery, query));
153
+ * }
154
+ * ```
155
+ */
156
+ type Params<TSpec> = QueryParams<TSpec>;
157
+ /**
158
+ * The query schema of a spec, for `@Http.query()`: it documents the filters, `sort`, `cursor`,
159
+ * `limit` and `fields` parameters in OpenAPI. Its static type is {@link Query.Params}, and other
160
+ * parameters are let through.
161
+ * @author Axel Nana <axel.nana@workbud.com>
162
+ * @param spec The query spec.
163
+ */
164
+ const schema: <TSpec extends QuerySpec>(spec: TSpec) => TObject<QuerySchemaProperties<TSpec>>;
165
+ /**
166
+ * Validates a query string against a spec.
167
+ * @author Axel Nana <axel.nana@workbud.com>
168
+ * @param spec The query spec.
169
+ * @param query The query, as Elysia parses it (`filter[status]` keys stay flat).
170
+ * @returns The parsed query, for a repository's `list()`.
171
+ * @throws {BadRequestError} A 400 problem whose `errors` name every invalid parameter.
172
+ */
173
+ const parse: (spec: QuerySpec, query?: Record<string, unknown>) => ParsedQuery;
174
+ /**
175
+ * Builds the cursor that points after a row.
176
+ * @author Axel Nana <axel.nana@workbud.com>
177
+ * @param query The parsed query, for its sort keys.
178
+ * @param row The last row of a page.
179
+ */
180
+ const cursorAfter: (query: ParsedQuery, row: Record<string, unknown>) => string;
181
+ /**
182
+ * Builds a page from rows fetched with one more row than the limit: the extra row only tells
183
+ * that a next page exists.
184
+ * @author Axel Nana <axel.nana@workbud.com>
185
+ * @param query The parsed query.
186
+ * @param rows Up to `limit + 1` rows.
187
+ */
188
+ const page: <T extends Record<string, unknown>>(query: ParsedQuery, rows: T[]) => CursorPage<T>;
189
+ /**
190
+ * The response schema of a page of resources.
191
+ * @author Axel Nana <axel.nana@workbud.com>
192
+ * @param item The schema of one row.
193
+ */
194
+ const pageSchema: <S extends TSchema>(item: S) => TObject<{
195
+ data: import("typebox").TArray<S>;
196
+ nextCursor: import("typebox").TUnion<[TString, import("typebox").TNull]>;
197
+ }>;
198
+ }
199
+ export {};
@@ -0,0 +1,35 @@
1
+ import type { TObject, TSchema } from '@elysiumjs/core';
2
+ import type { ModelClass } from './interfaces';
3
+ /**
4
+ * Options of {@link ModelResource}.
5
+ * @author Axel Nana <axel.nana@workbud.com>
6
+ * @template TKey The column names of the model.
7
+ */
8
+ export type ModelResourceOptions<TKey extends string = string> = {
9
+ /** Columns kept out of responses and of the OpenAPI document, such as `passwordHash`. */
10
+ hidden?: TKey[];
11
+ /** Only these columns are exposed. */
12
+ pick?: TKey[];
13
+ /** These columns are left out of the schema entirely. */
14
+ omit?: TKey[];
15
+ /** Extra fields the resource exposes, such as computed or related values. */
16
+ extend?: Record<string, TSchema>;
17
+ };
18
+ /**
19
+ * The response schema of a model: its select schema, with hidden, picked and omitted columns
20
+ * applied. Use it as a route's `response`: the handler can return model rows as they are, and
21
+ * the response holds only the exposed fields, with dates as ISO strings.
22
+ * @author Axel Nana <axel.nana@workbud.com>
23
+ * @param model The model class.
24
+ * @param options The columns to hide, pick or omit, and extra fields.
25
+ * @example
26
+ * ```ts
27
+ * const UserResource = ModelResource(User, { hidden: ['passwordHash'] });
28
+ *
29
+ * @Http.get('/:id', { response: UserResource })
30
+ * public show(@Http.param('id') id: string) {
31
+ * return this.users.find(id);
32
+ * }
33
+ * ```
34
+ */
35
+ export declare const ModelResource: <TModel extends ModelClass>(model: TModel, options?: ModelResourceOptions<Extract<keyof TModel['$inferSelect'], string>>) => TObject;
@@ -0,0 +1,68 @@
1
+ /** A callback run once the unit of work has committed or rolled back. */
2
+ type Hook = () => unknown | Promise<unknown>;
3
+ /** Runs a callback in a database transaction, joining the one open on the connection. */
4
+ export type TransactionRunner = <T>(callback: () => Promise<T>, connection?: string) => Promise<T>;
5
+ /**
6
+ * Wraps a driver's transaction function into the runner of units of work: the outermost run
7
+ * collects the `afterCommit` and `afterRollback` hooks of everything it calls, and runs them once
8
+ * it has committed or rolled back. Inner runs join it. Driver modules register the result as the
9
+ * `db.transaction` service, which `transactional` routes and {@link UnitOfWork.run} use.
10
+ * @author Axel Nana <axel.nana@workbud.com>
11
+ * @param transaction Runs a callback in a transaction, joining an open one.
12
+ */
13
+ export declare const withUnitOfWork: (transaction: TransactionRunner) => TransactionRunner;
14
+ export declare namespace UnitOfWork {
15
+ /**
16
+ * Runs a callback in a unit of work: one transaction on the connection that every repository
17
+ * call made from the callback uses. When a unit of work is already open, the callback joins
18
+ * it, so services compose without knowing which of them opened it.
19
+ *
20
+ * A callback that returns a `DomainError` rolls the work back, like one that throws, and the
21
+ * error is returned. A joined callback hands it to the one that opened the unit of work, which
22
+ * rolls back when it returns it too.
23
+ * @author Axel Nana <axel.nana@workbud.com>
24
+ * @param callback The work to run.
25
+ * @param options The connection of the transaction. Defaults to `default`.
26
+ * @throws {Error} When no database module provides the `db.transaction` service.
27
+ */
28
+ const run: <T>(callback: () => Promise<T>, options?: {
29
+ connection?: string;
30
+ }) => Promise<T>;
31
+ /** Whether a unit of work is open in the current async context. */
32
+ const active: () => boolean;
33
+ /**
34
+ * Runs a callback once the current unit of work has committed, such as publishing the events
35
+ * of an outbox. Outside a unit of work, it runs right away. A failing hook is logged.
36
+ * @author Axel Nana <axel.nana@workbud.com>
37
+ * @param hook The callback.
38
+ */
39
+ const afterCommit: (hook: Hook) => Promise<void>;
40
+ /**
41
+ * Runs a callback if the current unit of work rolls back, such as deleting an uploaded file.
42
+ * Outside a unit of work, it never runs.
43
+ * @author Axel Nana <axel.nana@workbud.com>
44
+ * @param hook The callback.
45
+ */
46
+ const afterRollback: (hook: Hook) => void;
47
+ }
48
+ /**
49
+ * Runs a method in a unit of work (see {@link UnitOfWork.run}): a new transaction, or the one
50
+ * already open. A method that returns a `DomainError` rolls back.
51
+ * @author Axel Nana <axel.nana@workbud.com>
52
+ * @param options The connection of the transaction. Defaults to `default`.
53
+ * @example
54
+ * ```ts
55
+ * @Service.register()
56
+ * class TransferService {
57
+ * @Transactional()
58
+ * public async transfer(from: string, to: string, amount: number) {
59
+ * await this.accounts.debit(from, amount);
60
+ * await this.accounts.credit(to, amount);
61
+ * }
62
+ * }
63
+ * ```
64
+ */
65
+ export declare const Transactional: (options?: {
66
+ connection?: string;
67
+ }) => MethodDecorator;
68
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elysiumjs/mnemosyne",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Database and caching for Elysium.js applications",
6
6
  "private": false,
@@ -40,10 +40,12 @@
40
40
  ],
41
41
  "sideEffects": false,
42
42
  "peerDependencies": {
43
- "@elysiumjs/core": "^0.10.0"
43
+ "@elysiumjs/core": "^0.11.1",
44
+ "@elysiumjs/redis": "^0.1.0"
44
45
  },
45
46
  "devDependencies": {
46
- "@elysiumjs/core": "0.10.0"
47
+ "@elysiumjs/core": "0.11.1",
48
+ "@elysiumjs/redis": "0.1.0"
47
49
  },
48
50
  "dependencies": {
49
51
  "cache-manager": "^7.2.9",