@elysiumjs/mnemosyne-drizzle 0.1.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +51 -0
- package/dist/cache.d.ts +60 -0
- package/dist/cache.js +1 -0
- package/dist/commands/migration_generate.command.d.ts +11 -0
- package/dist/commands/migration_generate.command.js +1 -0
- package/dist/commands/migration_run.command.d.ts +26 -0
- package/dist/commands/migration_run.command.js +10 -0
- package/dist/commands/project.d.ts +11 -0
- package/dist/commands/project.js +1 -0
- package/dist/database.d.ts +63 -23
- package/dist/database.js +1 -0
- package/dist/drizzle.module.d.ts +40 -0
- package/dist/drizzle.module.js +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -2
- package/dist/migrations.d.ts +45 -0
- package/dist/migrations.js +2 -0
- package/dist/model.d.ts +8 -8
- package/dist/model.js +1 -0
- package/dist/repository.d.ts +20 -5
- package/dist/repository.js +1 -0
- package/dist/table.d.ts +47 -123
- package/dist/table.js +1 -0
- package/dist/tenancy.d.ts +28 -11
- package/dist/tenancy.js +1 -0
- package/package.json +25 -13
- package/.turbo/turbo-build.log +0 -9
- package/.turbo/turbo-typecheck.log +0 -1
- package/coverage/.lcov.info.01ffde0d9f956dce.tmp +0 -14
- package/coverage/.lcov.info.2f80a4def3a82099.tmp +0 -343
- package/coverage/.lcov.info.560a2daaba0934cd.tmp +0 -366
- package/coverage/.lcov.info.7162c9b788dc0bc6.tmp +0 -366
- package/coverage/.lcov.info.b57c0b452c3901e5.tmp +0 -17
- package/coverage/.lcov.info.c47de8626f331696.tmp +0 -17
- package/coverage/.lcov.info.c710a42b25b08349.tmp +0 -343
- package/coverage/.lcov.info.dbc8f065467fc8a2.tmp +0 -287
- package/coverage/.lcov.info.de180a43301f82c0.tmp +0 -35
- package/coverage/.lcov.info.df18163ab929ab1b.tmp +0 -357
- package/coverage/.lcov.info.febf711c192f5c0c.tmp +0 -17
- package/coverage/lcov.info +0 -357
- package/src/database.ts +0 -175
- package/src/index.ts +0 -77
- package/src/model.ts +0 -171
- package/src/repository.ts +0 -113
- package/src/table.ts +0 -70
- package/src/tenancy.ts +0 -249
- package/tests/database.test.ts +0 -198
- package/tests/model.test.ts +0 -362
- package/tests/setup.ts +0 -10
- package/tests/table.test.ts +0 -72
- package/tests/tenancy.test.ts +0 -212
- package/tsconfig.json +0 -12
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @elysiumjs/mnemosyne-drizzle
|
|
2
|
+
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [[`68a0cdd`](https://github.com/na2axl/elysiumjs/commit/68a0cdd8b2e4a003d6e0af2d78985623cae40428), [`2c49acd`](https://github.com/na2axl/elysiumjs/commit/2c49acd3b552aa43765d70339fa216961499e1b5), [`76ba0ff`](https://github.com/na2axl/elysiumjs/commit/76ba0ff18f3c196cebc2189426328f67cb699f46), [`4ebf029`](https://github.com/na2axl/elysiumjs/commit/4ebf029611e6d753a4f1bfdbe247a75d954d9ded), [`bacce71`](https://github.com/na2axl/elysiumjs/commit/bacce7146f667a1a46bf474dc0344fb0111ae12a)]:
|
|
8
|
+
- @elysiumjs/core@0.10.0
|
|
9
|
+
- @elysiumjs/mnemosyne@0.3.1
|
|
10
|
+
|
|
11
|
+
## 0.3.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- [`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.
|
|
16
|
+
|
|
17
|
+
Breaking changes:
|
|
18
|
+
|
|
19
|
+
- `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:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
class OrganizationMiddleware extends SimpleTenantMiddleware {
|
|
23
|
+
protected async validateTenant(tenant: string): Promise<boolean> {
|
|
24
|
+
return Organization.exists(tenant);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `getTenantSchema()`, `registerTenantSchema()` and `wrapTenantSchema()` throw on tenant identifiers that do not match `TENANT_ID_PATTERN`.
|
|
30
|
+
- `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.
|
|
31
|
+
- `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.
|
|
32
|
+
- `@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.
|
|
33
|
+
- `@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.
|
|
34
|
+
- 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.
|
|
35
|
+
- 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.
|
|
36
|
+
- 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) => ...)`.
|
|
37
|
+
- `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.
|
|
38
|
+
- 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).
|
|
39
|
+
- `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.
|
|
40
|
+
- `Cache.memory` keeps a separate LRU per tag namespace, so `lruSize` applies to each namespace.
|
|
41
|
+
|
|
42
|
+
Fixes and additions:
|
|
43
|
+
|
|
44
|
+
- 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.
|
|
45
|
+
- `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.
|
|
46
|
+
- `@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`).
|
|
47
|
+
- 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.
|
|
48
|
+
- Cache namespaces (`Cache.redis.tags(...)`, `Cache.memory.tags(...)`) no longer overwrite each other: reads, deletes and `clear()` stay within their namespace.
|
|
49
|
+
- `getSessionTenant()` reads the Bun SQL result correctly, and `getCurrentTenant()` works without an application instance (for example under `Application.testing`).
|
|
50
|
+
- `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.
|
|
51
|
+
- `migration:generate` only removes `"public".` qualifiers from the migration it generates, and leaves string literals, comments and dollar-quoted bodies (`DO` blocks, functions) untouched.
|
|
52
|
+
- `DatabaseDriver.createConnection()` receives the connection name as an optional second argument.
|
|
53
|
+
- `KeyvRedis` deletes with a single `UNLINK` (or `DEL`) per call or batch and reports failed deletes in `clear()` as `error` events.
|
|
54
|
+
|
|
55
|
+
### Patch Changes
|
|
56
|
+
|
|
57
|
+
- [`ea419db`](https://github.com/na2axl/elysiumjs/commit/ea419db) Thanks [@na2axl](https://github.com/na2axl)! - `DrizzleModel()` accepts tables made with `pgTable()` again. Under TypeScript 7 it rejected every concrete table, so models and repositories over them did not typecheck.
|
|
58
|
+
- [`3987671`](https://github.com/na2axl/elysiumjs/commit/3987671) Thanks [@na2axl](https://github.com/na2axl)! - The package only contains its build output, README and changelog. It no longer ships sources, tests or local tool state.
|
|
59
|
+
- Updated dependencies [[`5fef4a7`](https://github.com/na2axl/elysiumjs/commit/5fef4a7199f502fc4fe035299e7670739459a108)]:
|
|
60
|
+
- @elysiumjs/core@0.9.0
|
|
61
|
+
- @elysiumjs/mnemosyne@0.3.0
|
|
62
|
+
|
|
63
|
+
## 0.2.0
|
|
64
|
+
|
|
65
|
+
### Minor Changes
|
|
66
|
+
|
|
67
|
+
- [`8ac91ab`](https://github.com/na2axl/elysiumjs/commit/8ac91ab676b1119b4eecf0ddb3fd0a3e84017cac) Thanks [@na2axl](https://github.com/na2axl)! - Upgrade dependencies to their latest major versions: wampy 8, zod 4, commander 15, @casl/ability 7, OpenTelemetry SDK 2 (sdk-node 0.300, resources 3), @sentry/bun 11. Generated projects now use TypeScript 7.
|
|
68
|
+
|
|
69
|
+
Breaking in `@elysiumjs/mnemosyne-drizzle`: it now requires drizzle-orm 1.0 (release candidate) and generated projects use drizzle-kit 1.0. `ColumnsWithTable` is no longer re-exported. `migration:generate` writes 1.0 migration folders (`<timestamp>_<name>/migration.sql`), and `migration:run` tracks those by folder name while still running flat `*.sql` files. Existing projects can upgrade their migrations folder with `drizzle-kit up`: `migration:run` recognises the moved migrations as already applied and renames their records. The `make:model` stub now generates a `pgTable` and a `DrizzleModel` built from it.
|
|
70
|
+
|
|
71
|
+
Breaking in `@elysiumjs/core`: the deprecated `@elysiajs/swagger` plugin is replaced by `@elysiajs/openapi`, and the app option `swagger` is renamed `openapi` (typed `ElysiaOpenAPIConfig | false`). Without a `path`, the docs now live at `/openapi` instead of `/swagger`. The UI options `scalarConfig` and `swaggerOptions` are now `scalar` and `swagger`. Elysia stays on 1.4 (now `^1.4.30`) until Elysia 2 and its OpenAPI plugin are stable.
|
|
72
|
+
|
|
73
|
+
Also in `@elysiumjs/core`: the WAMP `onChallenge` callback must return a string (or a promise of one); `null` is no longer accepted. `onError` now receives the error and `onReconnectSuccess` receives wampy's details object. `WampClient` is now wampy's own `Wampy` type. `onOpen` is called by Elysium once the connection is established, since wampy 8 no longer has that callback.
|
|
74
|
+
|
|
75
|
+
- [`07236b3`](https://github.com/na2axl/elysiumjs/commit/07236b386c2506590d74e8753ce847c1e184b3ee) Thanks [@na2axl](https://github.com/na2axl)! - Introduce the module system. Modules are now the only way to register anything in an Elysium.js app. This is a breaking change with no compatibility layer.
|
|
76
|
+
|
|
77
|
+
Breaking changes:
|
|
78
|
+
|
|
79
|
+
- `@Module.register()` takes `imports`, `services`, `exports`, `controllers`, `commands`, `jobs`, `prefix` and `env`. Services are private to their module unless exported (or global). The `beforeRegister` and `afterRegister` hooks are replaced by `onInit`, `onServe` and `onDestroy`.
|
|
80
|
+
- Configurable modules are bound with `with()`, for example `HermesModule.with({ level: 'debug' })`. `with()` accepts an object or a function.
|
|
81
|
+
- Decorators only record metadata. Importing a file no longer registers commands, services or event wiring, and the side-effect imports of the packages are removed. List commands, jobs and services in the module that owns them.
|
|
82
|
+
- `@Application.register()` no longer takes `database`, `redis`, `wamp` or `'elysium:hermes'`/`'elysium:cerberus'` keys. Use `DrizzleModule`, `RedisModule`, `WampModule`, `HermesModule` and `CerberusModule`. Package plugins are replaced by modules: add `SentryModule` instead of the `plugin()` function. `plugins` still accepts plain Elysia plugin factories.
|
|
83
|
+
- Constructing an `Application` no longer runs it. Use `await App.start()`; `Application.testing()` boots a module graph for tests. The `styx` binary loads `src/app.ts` (or `index.ts`) and starts the exported application, so a project entry no longer needs to import commands.
|
|
84
|
+
- The job system of core (`Job`, `Worker`, `WorkerPool`, `commandWork`) is removed. `@elysiumjs/heracles` is the only job system: extend its `Job`, list jobs in a module's `jobs`, declare queues with `HeraclesModule.with({ queues })`, and dispatch with `Heracles.dispatch()`.
|
|
85
|
+
- `@elysiumjs/styx` ships `StyxModule`, active in development only. The `migration:generate` and `migration:run` commands move to `DrizzleModule` and are available in production.
|
|
86
|
+
- `@elysiumjs/mnemosyne` ships `MnemosyneModule` for its caches (`redis: false` or `memory: false` opts out). `DrizzleModule` imports it.
|
|
87
|
+
- New modules: `HephaestusModule`, `ArgusModule` (`serviceName` is required), `SentryModule` (needs a `dsn` or `SENTRY_DSN`) and `CerberusModule` (`getSubject` is required).
|
|
88
|
+
- Module and service code throws typed errors (for example `InvalidModuleConfigError`) instead of exiting the process. `Application.run()` prints them and exits; command `run()` methods may still exit, and package plugin factories are unchanged.
|
|
89
|
+
- `Application.register` merges subclass props over the parent's (own props, then own metadata, then parent, then defaults), and concatenates `modules` parent first. Previously defaults and parent values won, so `openapi` could not be enabled.
|
|
90
|
+
- `Heracles.dispatch` starts a stopped queue on first use, so producers no longer start queues. `Queue.start()` is idempotent.
|
|
91
|
+
- `create-elysium` generates apps that use modules, and adds Sentry and Hephaestus to the `modules` list.
|
|
92
|
+
- `@Event.on`/`@Event.once` method listeners on classes in a loaded module's `services` or `controllers` are wired by the kernel and run in their module's context. Jobs, commands and middlewares are not wired, and an instance listener on a class that is not a service throws `EventListenerNotSupportedError`. Listeners on classes not listed in a loaded module no longer fire.
|
|
93
|
+
- New typed errors: `CircularDependencyError` (services that depend on each other, in sync and async resolution), `DuplicateModuleNameError` (two different module classes sharing a name; module scopes and config tokens are name based) and `EventListenerNotSupportedError`.
|
|
94
|
+
- Lazy boot contract: a CLI command initialises only its own module and that module's imports. Sync helpers such as `Database.getConnection()` work when the owning module's `onInit` is synchronous; otherwise they throw `ServiceNotReadyError` naming the module. Use `await Service.resolve()` or set `static boot = true` on the command.
|
|
95
|
+
|
|
96
|
+
### Patch Changes
|
|
97
|
+
|
|
98
|
+
- Updated dependencies [[`8ac91ab`](https://github.com/na2axl/elysiumjs/commit/8ac91ab676b1119b4eecf0ddb3fd0a3e84017cac), [`3625883`](https://github.com/na2axl/elysiumjs/commit/3625883dba92f1596e7444ee52f9399d8e9b049f), [`07236b3`](https://github.com/na2axl/elysiumjs/commit/07236b386c2506590d74e8753ce847c1e184b3ee)]:
|
|
99
|
+
- @elysiumjs/core@0.8.0
|
|
100
|
+
- @elysiumjs/mnemosyne@0.2.0
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @elysiumjs/mnemosyne-drizzle
|
|
2
|
+
|
|
3
|
+
Drizzle ORM driver for `@elysiumjs/mnemosyne`.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @elysiumjs/mnemosyne-drizzle
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Add `DrizzleModule` to your application. It declares the connections as global services, registers the tenancy strategy and provides the `migration:generate` and `migration:run` commands.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Application } from '@elysiumjs/core';
|
|
17
|
+
import { DrizzleModule } from '@elysiumjs/mnemosyne-drizzle';
|
|
18
|
+
|
|
19
|
+
@Application.register({
|
|
20
|
+
modules: [
|
|
21
|
+
DrizzleModule.with({
|
|
22
|
+
default: 'main',
|
|
23
|
+
connections: {
|
|
24
|
+
main: { connection: process.env.DATABASE_URL! }
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
]
|
|
28
|
+
})
|
|
29
|
+
export class App extends Application {}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`with()` also accepts a function, which is called when the module is booted: `DrizzleModule.with(() => ({ ... }))`. Set `tenancy` to switch between schema (default) and RLS tenancy.
|
|
33
|
+
|
|
34
|
+
Define models with the enhanced `pgTable`:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { DrizzleModel, DrizzleRepository, pgTable } from '@elysiumjs/mnemosyne-drizzle';
|
|
38
|
+
import { uuid, varchar } from 'drizzle-orm/pg-core';
|
|
39
|
+
|
|
40
|
+
const users = pgTable('users', {
|
|
41
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
42
|
+
name: varchar({ length: 255 }).notNull()
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export class UserModel extends DrizzleModel(users, { supportTenancy: true }) {}
|
|
46
|
+
export class UserRepository extends DrizzleRepository(UserModel) {}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
This project is licensed under the Apache License, Version 2.0. See the [LICENSE](../../LICENSE) file for more info.
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { CacheConfig } from 'drizzle-orm/cache/core/types';
|
|
2
|
+
import { Table } from 'drizzle-orm';
|
|
3
|
+
import { Cache as DrizzleCache } from 'drizzle-orm/cache/core';
|
|
4
|
+
/**
|
|
5
|
+
* Default time to live of cached query results, in seconds.
|
|
6
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_CACHE_TTL = 60;
|
|
9
|
+
/**
|
|
10
|
+
* Drizzle query cache backed by the framework's cache storage.
|
|
11
|
+
*
|
|
12
|
+
* - Results are cached per connection and tenant: every key starts with the configured prefix
|
|
13
|
+
* (if any) and the connection name, result keys include the current tenant (or `public`), and
|
|
14
|
+
* tags are invalidated for the current tenant. Each connection has its own transaction scope.
|
|
15
|
+
* - Invalidation works across application instances sharing the cache store: each table has a
|
|
16
|
+
* version token in the store, every result records the versions it was read at, and a mutation
|
|
17
|
+
* replaces the versions of the tables it touches. Table versions are shared by all tenants, as
|
|
18
|
+
* a mutation made for one tenant can change rows other tenants read (shared tables, RLS
|
|
19
|
+
* bypass). Every key is written with a TTL.
|
|
20
|
+
* - A result is only stored when the table versions are still the ones its query started at, and
|
|
21
|
+
* every concurrent miss of the key read the same versions: Drizzle does not pair a `put` with its
|
|
22
|
+
* `get`, so a result that may predate a mutation is never stored.
|
|
23
|
+
* - Inside a transaction, the cache is neither read nor written, since the transaction may read
|
|
24
|
+
* its own uncommitted changes. Tables mutated in a transaction are invalidated once it commits,
|
|
25
|
+
* and not at all when it rolls back.
|
|
26
|
+
*
|
|
27
|
+
* Remaining race: a mutation run outside a transaction is invalidated while its statement runs
|
|
28
|
+
* (Drizzle starts both together), so a read on another connection that completes between the
|
|
29
|
+
* invalidation and the commit can cache the previous rows until their TTL expires. Mutations run
|
|
30
|
+
* in a transaction (including RLS-scoped repository calls) are invalidated after the commit and
|
|
31
|
+
* are not affected.
|
|
32
|
+
*
|
|
33
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
34
|
+
*/
|
|
35
|
+
export declare class DrizzleDatabaseCache extends DrizzleCache {
|
|
36
|
+
#private;
|
|
37
|
+
/**
|
|
38
|
+
* @param cacheStrategy Whether queries are cached only when they opt in (`explicit`), or always.
|
|
39
|
+
* @param globalTtl The default time to live of cached results, in seconds.
|
|
40
|
+
* @param storageEngine The cache storage to use.
|
|
41
|
+
* @param connectionName The name of the connection, which prefixes every key.
|
|
42
|
+
* @param prefix A prefix for every key, before the connection name (empty for none).
|
|
43
|
+
*/
|
|
44
|
+
constructor(cacheStrategy?: 'explicit' | 'all', globalTtl?: number, storageEngine?: 'redis' | 'memory', connectionName?: string, prefix?: string);
|
|
45
|
+
strategy(): 'explicit' | 'all';
|
|
46
|
+
/**
|
|
47
|
+
* Runs a transaction so that the cache is bypassed inside it, and the tables it mutates are
|
|
48
|
+
* invalidated once it commits.
|
|
49
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
50
|
+
* @param run Opens and runs the transaction.
|
|
51
|
+
* @returns The return value of `run`.
|
|
52
|
+
*/
|
|
53
|
+
trackTransaction<T>(run: () => Promise<T>): Promise<T>;
|
|
54
|
+
get(key: string, tables?: string[], isTag?: boolean, autoInvalidate?: boolean): Promise<any[] | undefined>;
|
|
55
|
+
put(key: string, response: any, _tables: string[], isTag: boolean, config?: CacheConfig): Promise<void>;
|
|
56
|
+
onMutate(params: {
|
|
57
|
+
tags?: string | string[];
|
|
58
|
+
tables?: string | string[] | Table<any> | Table<any>[];
|
|
59
|
+
}): Promise<void>;
|
|
60
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{AsyncLocalStorage}from"node:async_hooks";import{Service,ServiceNotReadyError}from"@elysiumjs/core";import{createDatabaseCacheStorage,getCurrentTenant}from"@elysiumjs/mnemosyne";import{getTableName,is,Table}from"drizzle-orm";import{Cache as DrizzleCache}from"drizzle-orm/cache/core";export const DEFAULT_CACHE_TTL=60;const VERSION_TTL=24*60*60*1000;const MAX_PENDING_PER_KEY=16;const MAX_PENDING_KEYS=1000;const TABLE_SCHEMA=Symbol.for("drizzle:Schema");const tableKey=(table)=>{if(!is(table,Table))return table;const schema=table[TABLE_SCHEMA];return schema?`${schema}.${getTableName(table)}`:getTableName(table)};const toArray=(value)=>value===undefined?[]:Array.isArray(value)?value:[value];export class DrizzleDatabaseCache extends DrizzleCache{#storageEngine;#cache;#cacheStrategy;#globalTtl;#keyPrefix;#transactionScope=new AsyncLocalStorage;#pending=new Map;constructor(cacheStrategy="explicit",globalTtl=DEFAULT_CACHE_TTL,storageEngine="redis",connectionName="default",prefix=""){super();this.#cacheStrategy=cacheStrategy;this.#globalTtl=globalTtl;this.#storageEngine=storageEngine;this.#keyPrefix=prefix?`${prefix}:${connectionName}`:connectionName}async#storage(){if(this.#cache)return this.#cache;try{this.#cache=createDatabaseCacheStorage({storage:this.#storageEngine})}catch(error){if(!(error instanceof ServiceNotReadyError))throw error;await Service.resolve(error.service);this.#cache=createDatabaseCacheStorage({storage:this.#storageEngine})}return this.#cache}strategy(){return this.#cacheStrategy}async trackTransaction(run){const scope={tables:new Set,tags:new Set};const result=await this.#transactionScope.run(scope,run);await this.#invalidate([...scope.tables],[...scope.tags]);return result}async get(key,tables=[],isTag=false,autoInvalidate){if(this.#transactionScope.getStore())return;const storage=await this.#storage();const entryKey=this.#entryKey(key,isTag);const versionKeys=autoInvalidate===false?[]:tables.map((table)=>this.#versionKey(table));const[entry,...versions]=await storage.mget([entryKey,...versionKeys]);const missing=versionKeys.flatMap((versionKey,index)=>versions[index]===undefined?[{index,versionKey}]:[]);if(missing.length>0){const created=missing.map(({versionKey})=>({key:versionKey,value:crypto.randomUUID(),ttl:VERSION_TTL}));await storage.mset(created);for(const[i,{index}]of missing.entries())versions[index]=created[i].value}const signature=versions.join("|");const cached=entry;if(cached!==undefined&&cached.v===signature)return cached.r;this.#remember(entryKey,{signature,versionKeys,at:Date.now()});return}async put(key,response,_tables,isTag,config){if(this.#transactionScope.getStore())return;const entryKey=this.#entryKey(key,isTag);const pending=this.#live(entryKey);if(pending.length===0||new Set(pending.map((miss)=>miss.signature)).size>1){this.#pending.delete(entryKey);return}const[miss,...rest]=pending;if(rest.length>0)this.#pending.set(entryKey,rest);else this.#pending.delete(entryKey);const storage=await this.#storage();const current=await storage.mget(miss.versionKeys);if(current.join("|")!==miss.signature)return;const signature=miss.signature;const ttl=await this.#ttl(storage,entryKey,config);if(ttl<=0)return;await storage.set(entryKey,{v:signature,r:response},ttl)}async onMutate(params){const tables=toArray(params.tables).map(tableKey);const tags=toArray(params.tags);const scope=this.#transactionScope.getStore();if(scope){for(const table of tables)scope.tables.add(table);for(const tag of tags)scope.tags.add(tag);return}await this.#invalidate(tables,tags)}#remember(entryKey,miss){const pending=[...this.#live(entryKey),miss].slice(-MAX_PENDING_PER_KEY);this.#pending.delete(entryKey);this.#pending.set(entryKey,pending);while(this.#pending.size>MAX_PENDING_KEYS){this.#pending.delete(this.#pending.keys().next().value)}}#live(entryKey){const since=Date.now()-this.#globalTtl*1000;return(this.#pending.get(entryKey)??[]).filter((miss)=>miss.at>=since)}async#invalidate(tables,tags){if(tables.length===0&&tags.length===0)return;const storage=await this.#storage();if(tables.length>0){await storage.mset(tables.map((table)=>({key:this.#versionKey(table),value:crypto.randomUUID(),ttl:VERSION_TTL})))}if(tags.length>0){await storage.mdel(tags.map((tag)=>this.#entryKey(tag,true)))}}async#ttl(storage,entryKey,config){if(config?.px!==undefined)return config.px;if(config?.ex!==undefined)return config.ex*1000;if(config?.pxat!==undefined)return config.pxat-Date.now();if(config?.exat!==undefined)return config.exat*1000-Date.now();if(config?.keepTtl){const expiresAt=await storage.ttl(entryKey);if(expiresAt!==undefined&&expiresAt>Date.now())return expiresAt-Date.now()}return this.#globalTtl*1000}#entryKey(key,isTag){const kind=isTag?"tag":"query";return`${this.#keyPrefix}:${kind}:${getCurrentTenant()??"public"}:${key}`}#versionKey(table){return`${this.#keyPrefix}:version:${table}`}}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from '@elysiumjs/core';
|
|
2
|
+
/**
|
|
3
|
+
* Generates migration files.
|
|
4
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
5
|
+
*/
|
|
6
|
+
export declare class MigrationGenerateCommand extends Command {
|
|
7
|
+
static readonly command: string;
|
|
8
|
+
static readonly description: string;
|
|
9
|
+
private name?;
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var __decorate=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};var __metadata=function(k,v){if(typeof Reflect==="object"&&typeof Reflect.metadata==="function")return Reflect.metadata(k,v)};import{exists}from"node:fs/promises";import{Command,CommandArgumentType}from"@elysiumjs/core";import prompts from"prompts";import{stripPublicSchema}from"../migrations";import{getMigrationsPath,getProjectPath}from"./project";const listSqlFiles=async(migrationsPath)=>{if(!await exists(migrationsPath))return[];return Array.fromAsync(new Bun.Glob("**/*.sql").scan({cwd:migrationsPath,absolute:true,onlyFiles:true}))};let MigrationGenerateCommand=class MigrationGenerateCommand extends Command{static command="migration:generate";static description="Generates migration files.";name;async run(){if(!this.name){const answers=await prompts([{type:"text",name:"name",message:"Migration Name:",initial:"create_users_table",validate(value){if(value.length<1){return"Migration name cannot be empty"}return true}}],{onCancel:()=>{this.error("Operation cancelled.");process.exit(0)}});this.name=answers.name}const projectRoot=getProjectPath();const migrationsPath=getMigrationsPath();let schemaPath=`${projectRoot}/src/database/schemas`;if(await exists(`${projectRoot}/src/database/schema.ts`)){schemaPath=`${projectRoot}/src/database/schema.ts`}const existing=new Set(await listSqlFiles(migrationsPath));await Bun.$`bunx --bun drizzle-kit generate --dialect postgresql --schema ${schemaPath} --out ${migrationsPath} --name ${this.name}`;for(const path of await listSqlFiles(migrationsPath)){if(existing.has(path))continue;const file=Bun.file(path);await file.write(stripPublicSchema(await file.text()))}}};__decorate([Command.arg({description:"The name of the migration to create",type:CommandArgumentType.STRING}),__metadata("design:type",String)],MigrationGenerateCommand.prototype,"name",undefined);MigrationGenerateCommand=__decorate([Command.register()],MigrationGenerateCommand);export{MigrationGenerateCommand};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Command } from '@elysiumjs/core';
|
|
2
|
+
/**
|
|
3
|
+
* Execute migration files.
|
|
4
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
5
|
+
*/
|
|
6
|
+
export declare class MigrationRunCommand extends Command {
|
|
7
|
+
static readonly command: string;
|
|
8
|
+
static readonly description: string;
|
|
9
|
+
private tenant;
|
|
10
|
+
private connection;
|
|
11
|
+
private fresh;
|
|
12
|
+
private createSchema;
|
|
13
|
+
run(): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Runs the pending migrations in one transaction.
|
|
16
|
+
* @param sql The client.
|
|
17
|
+
*/
|
|
18
|
+
private migrate;
|
|
19
|
+
/**
|
|
20
|
+
* Checks that the tenant schema exists, or that it may be created: with `--create-schema`, or
|
|
21
|
+
* when the user agrees. Without a terminal to ask, a missing schema is an error.
|
|
22
|
+
* @param sql The client.
|
|
23
|
+
* @returns `false` if the schema must not be created.
|
|
24
|
+
*/
|
|
25
|
+
private ensureSchemaChoice;
|
|
26
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
var __decorate=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};var __metadata=function(k,v){if(typeof Reflect==="object"&&typeof Reflect.metadata==="function")return Reflect.metadata(k,v)};import{Command,CommandArgumentType}from"@elysiumjs/core";import{isValidTenantId}from"@elysiumjs/mnemosyne";import prompts from"prompts";import{Database}from"../database";import{getMigrationName,planMigrations}from"../migrations";import{getMigrationsPath}from"./project";let MigrationRunCommand=class MigrationRunCommand extends Command{static command="migration:run";static description="Runs pending migrations.";tenant="";connection="default";fresh=false;createSchema=false;async run(){if(!isValidTenantId(this.tenant)){this.error(`Invalid tenant ${this.bold(this.tenant)}: use 1 to 63 letters, digits, underscores or `+"dashes, not starting with pg_ and other than information_schema.");return process.exit(1)}if(!Database.connectionExists(this.connection)){this.error(`Connection ${this.bold(this.connection)} does not exist.`);return process.exit(1)}const sql=Database.getConnection(this.connection).$client;await sql.connect();try{if(!this.fresh&&!await this.ensureSchemaChoice(sql))return process.exit(1);await this.migrate(sql)}finally{await sql.close()}}async migrate(sql){await sql.begin(async(sql)=>{await sql`SELECT pg_advisory_xact_lock(hashtext(${`elysium_migrations:${this.tenant}`}))`;if(this.fresh){await sql`DROP SCHEMA IF EXISTS ${sql(this.tenant)} CASCADE;`}await sql`CREATE SCHEMA IF NOT EXISTS ${sql(this.tenant)};`;await sql`SET LOCAL search_path TO ${sql(this.tenant)}`;await sql`CREATE TABLE IF NOT EXISTS elysium_migrations (
|
|
2
|
+
id SERIAL PRIMARY KEY,
|
|
3
|
+
name TEXT NOT NULL,
|
|
4
|
+
batch INTEGER NOT NULL,
|
|
5
|
+
applied_at TIMESTAMP NOT NULL
|
|
6
|
+
);`;await sql`CREATE UNIQUE INDEX IF NOT EXISTS elysium_migrations_name_unique ON elysium_migrations (name);`;const rows=await sql`SELECT name, applied_at FROM elysium_migrations;`;const completedMigrations=rows.map((row)=>({name:row.name,appliedAt:row.applied_at}));const filesIt=new Bun.Glob("**/*.sql").scan({cwd:getMigrationsPath(),absolute:true,onlyFiles:true});const files=await Array.fromAsync(filesIt);const{pending:sqlFiles,renames}=planMigrations(files,completedMigrations);for(const{from,to}of renames){await sql`UPDATE elysium_migrations SET name = ${to} WHERE name = ${from};`;this.info(`Migration ${this.bold(from)} is now tracked as ${this.bold(to)}.`)}if(sqlFiles.length===0){this.success("No new migrations to apply.");return}const s=this.spinner(`Running migrations for tenant ${this.bold(this.tenant)}...`);const[{max}]=await sql`SELECT MAX(batch) + 1 AS max FROM elysium_migrations;`;const batch=max??0;for(const path of sqlFiles){await sql.file(path);const[{name}]=await sql`INSERT INTO elysium_migrations (name, batch, applied_at) VALUES (${getMigrationName(path)}, ${batch}, ${new Date}) RETURNING name;`;s.pause(`Migration ${this.bold(name)} applied successfully.`)}s.complete(`Migrations for tenant ${this.bold(this.tenant)} applied successfully.`)})}async ensureSchemaChoice(sql){const[{schema_exists}]=await sql`SELECT EXISTS (
|
|
7
|
+
SELECT schema_name
|
|
8
|
+
FROM information_schema.schemata
|
|
9
|
+
WHERE schema_name = ${this.tenant}
|
|
10
|
+
) AS schema_exists;`;if(schema_exists||this.createSchema)return true;if(!process.stdin.isTTY){this.error(`Schema ${this.bold(this.tenant)} does not exist. Run again with --create-schema to `+"create it.");return false}const answers=await prompts([{type:"confirm",name:"create",message:`Schema ${this.tenant} does not exist. Do you want to create it?`,initial:true}],{onCancel:()=>{this.error("Operation cancelled.");process.exit(0)}});if(!answers.create){this.error(`Schema ${this.bold(this.tenant)} creation cancelled.`);return false}return true}};__decorate([Command.arg({description:"The name of the tenant in which the migrations will be executed.",type:CommandArgumentType.STRING,required:true}),__metadata("design:type",String)],MigrationRunCommand.prototype,"tenant",undefined);__decorate([Command.arg({description:"The name of the connection to use.",type:CommandArgumentType.STRING,default:"default"}),__metadata("design:type",String)],MigrationRunCommand.prototype,"connection",undefined);__decorate([Command.arg({description:"Drop the entire schema before running the migrations.",type:CommandArgumentType.BOOLEAN,default:false}),__metadata("design:type",Boolean)],MigrationRunCommand.prototype,"fresh",undefined);__decorate([Command.arg({name:"create-schema",description:"Create the tenant schema without asking when it does not exist.",type:CommandArgumentType.BOOLEAN,default:false}),__metadata("design:type",Boolean)],MigrationRunCommand.prototype,"createSchema",undefined);MigrationRunCommand=__decorate([Command.register()],MigrationRunCommand);export{MigrationRunCommand};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gets the root path of the Elysium.js project: the closest parent holding an `.elysiumrc`, or
|
|
3
|
+
* the current directory when there is none.
|
|
4
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
5
|
+
*/
|
|
6
|
+
export declare const getProjectPath: () => string;
|
|
7
|
+
/**
|
|
8
|
+
* Gets the directory holding the migrations of the Elysium.js project.
|
|
9
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
10
|
+
*/
|
|
11
|
+
export declare const getMigrationsPath: () => string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{existsSync}from"node:fs";import{dirname,join}from"node:path";export const getProjectPath=()=>{const startPath=process.cwd();let dir=startPath;while(true){if(existsSync(join(dir,".elysiumrc")))return dir;const parentDir=dirname(dir);if(dir===parentDir)break;dir=parentDir}return startPath};export const getMigrationsPath=()=>join(getProjectPath(),"src/database/migrations");
|
package/dist/database.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { DatabaseCacheConfig, DatabaseDriver } from '@elysiumjs/mnemosyne';
|
|
2
2
|
import type { DrizzleConfig } from 'drizzle-orm';
|
|
3
|
-
import type { BunSQLDatabase } from 'drizzle-orm/bun-sql';
|
|
4
|
-
import
|
|
3
|
+
import type { BunSQLDatabase } from 'drizzle-orm/bun-sql/postgres';
|
|
4
|
+
import { ElysiumError } from '@elysiumjs/core';
|
|
5
5
|
import { AbstractDatabase } from '@elysiumjs/mnemosyne';
|
|
6
|
-
|
|
7
|
-
import { Cache as DrizzleCache } from 'drizzle-orm/cache/core';
|
|
6
|
+
export { DEFAULT_CACHE_TTL, DrizzleDatabaseCache } from './cache';
|
|
8
7
|
/**
|
|
9
8
|
* Connection configuration for Drizzle ORM.
|
|
10
9
|
*
|
|
@@ -30,28 +29,38 @@ export type DrizzleConnection = BunSQLDatabase<Record<string, never>> & {
|
|
|
30
29
|
$client: Bun.SQL;
|
|
31
30
|
};
|
|
32
31
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* Extends the Drizzle `Cache` base class and implements the
|
|
36
|
-
* framework's `DatabaseCacheStrategy` interface so that query
|
|
37
|
-
* results can be transparently cached and invalidated.
|
|
32
|
+
* Thrown in RLS mode when work for one tenant would join a transaction bound to another.
|
|
38
33
|
*
|
|
34
|
+
* A transaction is bound to the tenant set when it is opened, for its whole life. Work for another
|
|
35
|
+
* tenant must run outside the transaction, or in its own `withRLS()` before the transaction opens.
|
|
39
36
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
40
37
|
*/
|
|
41
|
-
export declare class
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
put(key: string, response: any, tables: string[], _isTag: boolean, config?: CacheConfig): Promise<void>;
|
|
50
|
-
onMutate(params: {
|
|
51
|
-
tags?: string | string[];
|
|
52
|
-
tables?: string | string[] | Table<any> | Table<any>[];
|
|
53
|
-
}): Promise<void>;
|
|
38
|
+
export declare class TenantMismatchError extends ElysiumError {
|
|
39
|
+
readonly transactionTenant: string | null;
|
|
40
|
+
readonly tenant: string;
|
|
41
|
+
/**
|
|
42
|
+
* @param transactionTenant The tenant the open transaction is bound to, or `null` if none.
|
|
43
|
+
* @param tenant The tenant of the work that tried to join it.
|
|
44
|
+
*/
|
|
45
|
+
constructor(transactionTenant: string | null, tenant: string);
|
|
54
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Binds a transaction to a tenant: sets the RLS tenant variable for the rest of the transaction.
|
|
49
|
+
* @internal
|
|
50
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
51
|
+
* @param tx The transaction, just opened.
|
|
52
|
+
* @param tenant The tenant identifier.
|
|
53
|
+
*/
|
|
54
|
+
export declare const bindTransactionTenant: (tx: DrizzleConnection, tenant: string) => Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Checks that work for a tenant may join an open transaction: it must be bound to that tenant.
|
|
57
|
+
* @internal
|
|
58
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
59
|
+
* @param tx The open transaction.
|
|
60
|
+
* @param tenant The tenant of the work.
|
|
61
|
+
* @throws {TenantMismatchError} If the transaction is bound to another tenant, or to none.
|
|
62
|
+
*/
|
|
63
|
+
export declare const assertTransactionTenant: (tx: DrizzleConnection, tenant: string) => void;
|
|
55
64
|
/**
|
|
56
65
|
* Drizzle-specific database manager.
|
|
57
66
|
*
|
|
@@ -61,6 +70,38 @@ export declare class DrizzleDatabaseCache extends DrizzleCache {
|
|
|
61
70
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
62
71
|
*/
|
|
63
72
|
declare class DrizzleDatabase extends AbstractDatabase<DrizzleConnection, DrizzleConnectionProps> {
|
|
73
|
+
/**
|
|
74
|
+
* Retrieves the connection with the given name, or the transaction open on it.
|
|
75
|
+
*
|
|
76
|
+
* In RLS mode, an open transaction bound to a tenant is only returned to work for that tenant,
|
|
77
|
+
* the current one (or `public`).
|
|
78
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
79
|
+
* @param name The connection name.
|
|
80
|
+
* @returns The connection, or its open transaction.
|
|
81
|
+
* @throws {TenantMismatchError} If the open transaction is bound to another tenant.
|
|
82
|
+
*/
|
|
83
|
+
getConnection(name?: string): DrizzleConnection;
|
|
84
|
+
/**
|
|
85
|
+
* Runs a callback in a transaction, joining the one open on the connection if any.
|
|
86
|
+
*
|
|
87
|
+
* In RLS mode, a new transaction is bound to the current tenant (or `public`), and an open one
|
|
88
|
+
* is only joined when it is bound to the current tenant.
|
|
89
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
90
|
+
* @param callback The function to run with the transaction.
|
|
91
|
+
* @param name The connection name.
|
|
92
|
+
* @returns The return value of the callback.
|
|
93
|
+
* @throws {TenantMismatchError} If the open transaction is bound to another tenant.
|
|
94
|
+
*/
|
|
95
|
+
transaction<T>(callback: (tx: DrizzleConnection) => Promise<T>, name?: string): Promise<T>;
|
|
96
|
+
/**
|
|
97
|
+
* Creates a Drizzle connection from its properties. Used by the `DrizzleModule` to build
|
|
98
|
+
* the `db.connection.{name}` services.
|
|
99
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
100
|
+
* @param props The connection properties.
|
|
101
|
+
* @param name The name of the connection, which scopes its query cache.
|
|
102
|
+
* @returns A new connection.
|
|
103
|
+
*/
|
|
104
|
+
createConnection(props: DrizzleConnectionProps, name?: string): DrizzleConnection;
|
|
64
105
|
protected driver: DatabaseDriver<DrizzleConnection, DrizzleConnectionProps>;
|
|
65
106
|
}
|
|
66
107
|
/**
|
|
@@ -68,4 +109,3 @@ declare class DrizzleDatabase extends AbstractDatabase<DrizzleConnection, Drizzl
|
|
|
68
109
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
69
110
|
*/
|
|
70
111
|
export declare const Database: DrizzleDatabase;
|
|
71
|
-
export {};
|
package/dist/database.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{ElysiumError}from"@elysiumjs/core";import{AbstractDatabase,getCurrentTenant,getTenancyConfig}from"@elysiumjs/mnemosyne";import{sql}from"drizzle-orm";import{drizzle}from"drizzle-orm/bun-sql";import{DrizzleDatabaseCache}from"./cache";export{DEFAULT_CACHE_TTL,DrizzleDatabaseCache}from"./cache";export class TenantMismatchError extends ElysiumError{transactionTenant;tenant;constructor(transactionTenant,tenant){super(`Cannot run work for tenant "${tenant}" in a transaction bound to `+(transactionTenant===null?"no tenant":`tenant "${transactionTenant}"`)+". Run it outside the transaction, or in its own withRLS() before opening the transaction.");this.transactionTenant=transactionTenant;this.tenant=tenant}}const transactionTenants=new WeakMap;export const bindTransactionTenant=async(tx,tenant)=>{const varName=getTenancyConfig().rls?.sessionVariable??"app.current_tenant";await tx.execute(sql`SELECT set_config(${varName}, ${tenant}, true)`);transactionTenants.set(tx,tenant)};export const assertTransactionTenant=(tx,tenant)=>{const bound=transactionTenants.get(tx);if(bound!==tenant)throw new TenantMismatchError(bound??null,tenant)};class DrizzleDatabase extends AbstractDatabase{getConnection(name="default"){const connection=super.getConnection(name);if(transactionTenants.has(connection)&&getTenancyConfig().mode==="rls"){assertTransactionTenant(connection,getCurrentTenant()??"public")}return connection}async transaction(callback,name="default"){if(getTenancyConfig().mode==="rls"){const active=this.getActiveTransaction(this.getBaseConnection(name));if(active!==undefined)assertTransactionTenant(active,getCurrentTenant()??"public")}return super.transaction(callback,name)}createConnection(props,name="default"){return this.driver.createConnection(props,name)}driver={createConnection(config,name="default"){const cache=config.cache===undefined?undefined:config.cache===true?new DrizzleDatabaseCache(undefined,undefined,undefined,name):config.cache.enabled!==false?new DrizzleDatabaseCache(config.cache.strategy,config.cache.ttl,config.cache.storage,name,config.cache.prefix):undefined;const connection=drizzle({...config,cache});if(cache===undefined)return connection;const transaction=connection.transaction.bind(connection);return Object.assign(connection,{transaction:(callback,txConfig)=>cache.trackTransaction(()=>transaction(callback,txConfig))})},withTransaction(connection,callback){return connection.transaction(async(tx)=>{if(getTenancyConfig().mode==="rls"){await bindTransactionTenant(tx,getCurrentTenant()??"public")}return callback(tx)})},getRawClient(connection){return connection.$client}}}export const Database=new DrizzleDatabase;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { TenancyConfig } from '@elysiumjs/mnemosyne';
|
|
2
|
+
import type { DrizzleConnectionProps } from './database';
|
|
3
|
+
/**
|
|
4
|
+
* Configuration of the {@link DrizzleModule}.
|
|
5
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
6
|
+
*/
|
|
7
|
+
export type DrizzleModuleConfig = {
|
|
8
|
+
/** The name of the connection that `default` resolves to. */
|
|
9
|
+
default: string;
|
|
10
|
+
/** The connections to declare, by name. */
|
|
11
|
+
connections: Record<string, DrizzleConnectionProps>;
|
|
12
|
+
/** The tenancy configuration. Defaults to schema-based tenancy. */
|
|
13
|
+
tenancy?: TenancyConfig;
|
|
14
|
+
};
|
|
15
|
+
declare const DrizzleModule_base: (abstract new () => {
|
|
16
|
+
readonly config: DrizzleModuleConfig;
|
|
17
|
+
onInit(): Promise<void> | void;
|
|
18
|
+
onDestroy(): Promise<void> | void;
|
|
19
|
+
onServe(_app: import("elysia").default<any>): Promise<void> | void;
|
|
20
|
+
}) & {
|
|
21
|
+
readonly config: string;
|
|
22
|
+
with(config: Partial<DrizzleModuleConfig> | (() => Partial<DrizzleModuleConfig> | Promise<Partial<DrizzleModuleConfig>>)): import("@elysiumjs/core").ConfiguredModule<DrizzleModuleConfig>;
|
|
23
|
+
resolveConfig(input?: Partial<DrizzleModuleConfig> | undefined): DrizzleModuleConfig;
|
|
24
|
+
register<TConfig_1 = any>(props?: import("@elysiumjs/core").ModuleProps<TConfig_1>): (target: import("@elysiumjs/core").ModuleClass) => void;
|
|
25
|
+
configurable<TConfig extends object>(options?: {
|
|
26
|
+
defaults?: Partial<TConfig>;
|
|
27
|
+
required?: (keyof TConfig)[];
|
|
28
|
+
}): any;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Declares Drizzle connections as global services named `db.connection.{name}`, plus
|
|
32
|
+
* `db.connection.default` aliasing the configured default, `db.transaction` (used by
|
|
33
|
+
* `@transactional` handlers), and registers the tenancy config and strategy. Connections are created on first use. Also provides the migration commands.
|
|
34
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
35
|
+
*/
|
|
36
|
+
export declare class DrizzleModule extends DrizzleModule_base {
|
|
37
|
+
/** Closes the clients of the connections that were created. */
|
|
38
|
+
onDestroy(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var __decorate=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{Module,needsDefaultAlias,runAll,Service}from"@elysiumjs/core";import{AbstractDatabase,DATABASE_TRANSACTION_SERVICE,MnemosyneModule,TENANCY_CONFIG_SERVICE,TENANCY_STRATEGY_SERVICE}from"@elysiumjs/mnemosyne";import{MigrationGenerateCommand}from"./commands/migration_generate.command";import{MigrationRunCommand}from"./commands/migration_run.command";import{Database}from"./database";import{DrizzleRLSTenancy,DrizzleSchemaTenancy}from"./tenancy";let DrizzleModule=class DrizzleModule extends Module.configurable({defaults:{default:"default"},required:["connections"]}){async onDestroy(){const names=Object.keys(this.config.connections).map(AbstractDatabase.getConnectionName);await runAll(names.filter((name)=>Service.isConstructed(name)).map((name)=>()=>Service.get(name)?.$client.close()))}};DrizzleModule=__decorate([Module.register({imports:[MnemosyneModule],commands:[MigrationGenerateCommand,MigrationRunCommand],services:(config)=>{const alias=needsDefaultAlias("DrizzleModule",config);const tenancy=config.tenancy??{mode:"schema"};return[...Object.entries(config.connections).map(([name,props])=>({name:AbstractDatabase.getConnectionName(name),global:true,factory:()=>Database.createConnection(props,name)})),...alias?[{name:AbstractDatabase.getConnectionName("default"),global:true,factory:(_config,get)=>get(AbstractDatabase.getConnectionName(config.default))}]:[],{name:DATABASE_TRANSACTION_SERVICE,global:true,factory:()=>(callback,connection)=>Database.transaction(()=>callback(),connection)},{name:TENANCY_CONFIG_SERVICE,global:true,factory:()=>tenancy},{name:TENANCY_STRATEGY_SERVICE,global:true,factory:()=>tenancy.mode==="rls"?new DrizzleRLSTenancy:new DrizzleSchemaTenancy}]}})],DrizzleModule);export{DrizzleModule};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export * from 'drizzle-orm';
|
|
2
2
|
export * from 'drizzle-orm/pg-core';
|
|
3
|
-
export type {
|
|
3
|
+
export type { SelectedFields, SelectedFieldsFlat, SelectedFieldsOrdered, TableConfig } from 'drizzle-orm/pg-core';
|
|
4
4
|
export { pgTable, getTableBuilders } from './table';
|
|
5
|
-
export { Database, DrizzleDatabaseCache } from './database';
|
|
5
|
+
export { Database, DEFAULT_CACHE_TTL, DrizzleDatabaseCache, TenantMismatchError } from './database';
|
|
6
6
|
export type { DrizzleConnectionProps, DrizzleConnection } from './database';
|
|
7
7
|
export { DrizzleModel, createSchemaFromDrizzle, drizzleAdapter, type DrizzleModelOptions } from './model';
|
|
8
|
+
export { DrizzleModule, type DrizzleModuleConfig } from './drizzle.module';
|
|
8
9
|
export { DrizzleRepository } from './repository';
|
|
9
|
-
export { DrizzleSchemaTenancy, DrizzleRLSTenancy, wrapTenantSchema, registerTenantSchema, getTenantSchema, withRLS,
|
|
10
|
+
export { DrizzleSchemaTenancy, DrizzleRLSTenancy, wrapTenantSchema, registerTenantSchema, getTenantSchema, withRLS, getSessionTenant, createRLSPolicy } from './tenancy';
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import{Application as T,Event as d}from"@elysiumjs/core";import{registerTenancyStrategy as N}from"@elysiumjs/mnemosyne";import{AbstractDatabase as j,createDatabaseCacheStorage as w}from"@elysiumjs/mnemosyne";import{getTableName as $,is as B,Table as I}from"drizzle-orm";import{drizzle as L}from"drizzle-orm/bun-sql";import{Cache as P}from"drizzle-orm/cache/core";class X extends P{cacheStrategy;globalTtl;#F;usedTablesPerKey={};constructor(F="explicit",G=1,H="redis"){super();this.cacheStrategy=F;this.globalTtl=G;this.#F=w({storage:H})}strategy(){return this.cacheStrategy}async get(F){return await this.#F.get(F)??void 0}async put(F,G,H,J,K){await this.#F.set(F,G,(K?.ex??this.globalTtl)*1000);for(let M of H){let U=this.usedTablesPerKey[M];if(U===void 0)this.usedTablesPerKey[M]=[F];else U.push(F)}}async onMutate(F){let G=F.tags?Array.isArray(F.tags)?F.tags:[F.tags]:[],H=F.tables?Array.isArray(F.tables)?F.tables:[F.tables]:[],J=new Set;for(let K of H){let M=B(K,I)?$(K):K,U=this.usedTablesPerKey[M]??[];for(let E of U)J.add(E)}if(J.size>0||G.length>0){await this.#F.mdel(G),await this.#F.mdel([...J]);for(let K of H){let M=B(K,I)?$(K):K;this.usedTablesPerKey[M]=[]}}}}class O extends j{driver={createConnection(F){let G=F.cache===void 0?void 0:F.cache===!0?new X:F.cache.enabled!==!1?new X(F.cache.strategy,F.cache.ttl,F.cache.storage):void 0;return L({...F,cache:G})},withTransaction(F,G){return F.transaction((H)=>G(H))},getRawClient(F){return F.$client}}}var V=new O;import{getTenancyConfig as q}from"@elysiumjs/mnemosyne";import{sql as A}from"drizzle-orm";import{getTableConfig as v,pgPolicy as s,pgSchema as x,pgTable as C}from"drizzle-orm/pg-core";import{pgTable as g}from"drizzle-orm/pg-core";var R=new WeakMap,Q=(F)=>{return R.get(F)};var Y=new Map,W=new Map,S=(F)=>{return W.get(F)??k(F)},k=(F)=>{if(W.has(F))return W.get(F);let G=x(F);return W.set(F,G),G},D=(F,G,H)=>{if(F==="public")return C(G,H);let J=`${F}.${G}`;if(Y.has(J))return Y.get(J);let M=S(F).table(G,H);return Y.set(J,M),M};class Z{mode="schema";resolveTable(F,G){let H=Q(F);if(!H)throw Error("No column builders registered for table. Use pgTable() from '@elysiumjs/mnemosyne-drizzle' for tenant support.");let J=v(F);return D(G,J.name,H)}async withIsolation(F,G,H){return H()}}class _{mode="rls";resolveTable(F,G){return F}async withIsolation(F,G,H){let K=q().rls?.sessionVariable??"app.current_tenant";return F.transaction(async(M)=>{return await M.execute(A`SELECT set_config(${K}, ${G}, true)`),H()})}}export*from"drizzle-orm";export*from"drizzle-orm/pg-core";d.once("elysium:app:launched",()=>{let G=T.instance.getConfig("elysium:mnemosyne");if(G?.database){for(let[H,J]of Object.entries(G.database.connections))V.registerConnection(H,J);if(V.connectionExists(G.database.default))V.setDefaultConnection(G.database.default)}if(G?.tenancy){let H=G.tenancy.mode==="rls"?new _:new Z;N(H)}});export{QF as wrapTenantSchema,wF as withRLS,LF as setConnectionTenant,EF as registerTenantSchema,JF as pgTable,jF as getTenantSchema,KF as getTableBuilders,PF as getSessionTenant,ZF as drizzleAdapter,YF as createSchemaFromDrizzle,RF as createRLSPolicy,IF as DrizzleSchemaTenancy,$F as DrizzleRepository,OF as DrizzleRLSTenancy,XF as DrizzleModel,VF as DrizzleDatabaseCache,UF as Database};
|
|
1
|
+
export*from"drizzle-orm";export*from"drizzle-orm/pg-core";export{pgTable,getTableBuilders}from"./table";export{Database,DEFAULT_CACHE_TTL,DrizzleDatabaseCache,TenantMismatchError}from"./database";export{DrizzleModel,createSchemaFromDrizzle,drizzleAdapter}from"./model";export{DrizzleModule}from"./drizzle.module";export{DrizzleRepository}from"./repository";export{DrizzleSchemaTenancy,DrizzleRLSTenancy,wrapTenantSchema,registerTenantSchema,getTenantSchema,withRLS,getSessionTenant,createRLSPolicy}from"./tenancy";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** A migration recorded as applied in `elysium_migrations`. */
|
|
2
|
+
export type AppliedMigration = {
|
|
3
|
+
name: string;
|
|
4
|
+
appliedAt: Date;
|
|
5
|
+
};
|
|
6
|
+
/** The migrations to run, and the applied records to rename to their new names. */
|
|
7
|
+
export type MigrationPlan = {
|
|
8
|
+
pending: string[];
|
|
9
|
+
renames: {
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
}[];
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Gets the name under which a migration file is tracked. Drizzle Kit 1.0 stores each migration
|
|
16
|
+
* as `<folder>/migration.sql`, in which case the folder name identifies the migration.
|
|
17
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
18
|
+
* @param path The path of the migration file.
|
|
19
|
+
*/
|
|
20
|
+
export declare const getMigrationName: (path: string) => string;
|
|
21
|
+
/**
|
|
22
|
+
* Decides which migration files to run.
|
|
23
|
+
*
|
|
24
|
+
* `drizzle-kit up` moves each flat `<prefix>_<name>.sql` file into a
|
|
25
|
+
* `<timestamp>_<name>/migration.sql` folder, where the timestamp is when the migration was
|
|
26
|
+
* generated. An applied record whose flat file is gone is matched, in order, to a folder with
|
|
27
|
+
* the same `<name>` generated before the record was applied; that folder is not run again and
|
|
28
|
+
* the record is renamed. Folders generated later are new migrations and run.
|
|
29
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
30
|
+
* @param files The paths of the migration files.
|
|
31
|
+
* @param applied The applied migrations.
|
|
32
|
+
*/
|
|
33
|
+
export declare const planMigrations: (files: string[], applied: AppliedMigration[]) => MigrationPlan;
|
|
34
|
+
/**
|
|
35
|
+
* Removes the `"public".` qualifier Drizzle Kit writes before identifiers, so that a migration
|
|
36
|
+
* runs in the schema selected by the `search_path` (the tenant schema).
|
|
37
|
+
*
|
|
38
|
+
* Only identifier qualifiers are changed: string literals, dollar-quoted bodies (functions, `DO`
|
|
39
|
+
* blocks), comments and other quoted identifiers are kept as is. Qualifiers inside a `DO` block
|
|
40
|
+
* are therefore kept: such blocks are not rewritten, since their contents are not parsed here.
|
|
41
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
42
|
+
* @param sql The migration SQL.
|
|
43
|
+
* @returns The SQL without the public schema qualifiers.
|
|
44
|
+
*/
|
|
45
|
+
export declare const stripPublicSchema: (sql: string) => string;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{basename,dirname}from"node:path";const FOLDER_MIGRATION_FILE="migration.sql";export const getMigrationName=(path)=>basename(path)===FOLDER_MIGRATION_FILE?basename(dirname(path)):basename(path);const descriptiveName=(name)=>name.replace(/\.sql$/,"").split("_").slice(1).join("_");const folderTime=(name)=>{const match=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name);return match?Date.UTC(+match[1],+match[2]-1,+match[3],+match[4],+match[5],+match[6]):undefined};export const planMigrations=(files,applied)=>{const appliedNames=new Set(applied.map(({name})=>name));const fileNames=new Set(files.map(getMigrationName));const moved=new Map;for(const record of[...applied].sort((a,b)=>a.name.localeCompare(b.name))){if(!record.name.endsWith(".sql")||fileNames.has(record.name))continue;const key=descriptiveName(record.name);moved.set(key,[...moved.get(key)??[],record])}const plan={pending:[],renames:[]};for(const file of[...files].sort()){const name=getMigrationName(file);if(appliedNames.has(name))continue;const from=basename(file)===FOLDER_MIGRATION_FILE?takeMoved(moved,name):undefined;if(from)plan.renames.push({from,to:name});else plan.pending.push(file)}return plan};const takeMoved=(moved,name)=>{const records=moved.get(descriptiveName(name));const generatedAt=folderTime(name);const index=records?.findIndex(({appliedAt})=>generatedAt===undefined||generatedAt<=appliedAt.getTime())??-1;return index<0?undefined:records.splice(index,1)[0].name};const IDENTIFIER_CHAR=/[A-Za-z0-9_$]/;const PUBLIC_QUALIFIER='"public".';const endOfQuoted=(sql,start,quote,backslash)=>{let i=start+1;while(i<sql.length){if(backslash&&sql[i]==="\\")i+=2;else if(sql[i]!==quote)i++;else if(sql[i+1]===quote)i+=2;else return i+1}return sql.length};const endOfVerbatim=(sql,i)=>{const previous=sql[i-1]??"";if(sql.startsWith("--",i)){const end=sql.indexOf(`
|
|
2
|
+
`,i);return end<0?sql.length:end}if(sql.startsWith("/*",i)){const end=sql.indexOf("*/",i+2);return end<0?sql.length:end+2}if(sql[i]==="'"){const escape=/[Ee]/.test(previous)&&!IDENTIFIER_CHAR.test(sql[i-2]??"");return endOfQuoted(sql,i,"'",escape)}if(sql[i]==="$"&&!IDENTIFIER_CHAR.test(previous)){const tag=/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i,i+64))?.[0];if(tag){const end=sql.indexOf(tag,i+tag.length);return end<0?sql.length:end+tag.length}}return-1};export const stripPublicSchema=(sql)=>{let output="";let i=0;while(i<sql.length){const verbatim=endOfVerbatim(sql,i);if(verbatim>=0){output+=sql.slice(i,verbatim);i=verbatim}else if(sql.startsWith(PUBLIC_QUALIFIER,i)&&sql[i+PUBLIC_QUALIFIER.length]==='"'){i+=PUBLIC_QUALIFIER.length}else if(sql[i]==='"'){const end=endOfQuoted(sql,i,'"',false);output+=sql.slice(i,end);i=end}else{output+=sql[i];i++}}return output};
|