@elysiumjs/mnemosyne-drizzle 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/dist/cache.d.ts +60 -0
- package/dist/cache.js +1 -0
- package/dist/commands/migration_generate.command.js +1 -1
- package/dist/commands/migration_run.command.d.ts +13 -0
- package/dist/commands/migration_run.command.js +6 -11
- package/dist/commands/project.d.ts +11 -0
- package/dist/commands/project.js +1 -0
- package/dist/database.d.ts +55 -23
- package/dist/database.js +1 -1
- package/dist/drizzle.module.d.ts +2 -2
- package/dist/drizzle.module.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/migrations.d.ts +12 -0
- package/dist/migrations.js +2 -1
- package/dist/model.d.ts +5 -5
- package/dist/repository.d.ts +20 -5
- package/dist/repository.js +1 -1
- package/dist/tenancy.d.ts +25 -8
- package/dist/tenancy.js +1 -1
- package/package.json +19 -9
- package/.omc/state/agent-replay-3920b579-6d8e-4f5f-924a-319202936685.jsonl +0 -7
- package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/last-tool-error-state.json +0 -7
- package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/mission-state.json +0 -93
- package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/pre-tool-advisory-throttle.json +0 -18
- package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/subagent-tracking-state.json +0 -61
- package/.turbo/turbo-build.log +0 -3
- package/scripts/minify.ts +0 -10
- package/src/commands/migration_generate.command.ts +0 -101
- package/src/commands/migration_run.command.ts +0 -160
- package/src/database.ts +0 -205
- package/src/drizzle.module.ts +0 -97
- package/src/index.ts +0 -47
- package/src/migrations.ts +0 -100
- package/src/model.ts +0 -178
- package/src/repository.ts +0 -113
- package/src/table.ts +0 -70
- package/src/tenancy.ts +0 -249
- package/tests/drizzle.module.test.ts +0 -201
- package/tests/migrations.test.ts +0 -123
- package/tests/model.test.ts +0 -362
- package/tests/setup.ts +0 -0
- package/tests/table.test.ts +0 -72
- package/tests/tenancy.test.ts +0 -212
- package/tsconfig.json +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# @elysiumjs/mnemosyne-drizzle
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [[`68a0cdd`](https://github.com/na2axl/elysiumjs/commit/68a0cdd8b2e4a003d6e0af2d78985623cae40428), [`2c49acd`](https://github.com/na2axl/elysiumjs/commit/2c49acd3b552aa43765d70339fa216961499e1b5), [`76ba0ff`](https://github.com/na2axl/elysiumjs/commit/76ba0ff18f3c196cebc2189426328f67cb699f46), [`4ebf029`](https://github.com/na2axl/elysiumjs/commit/4ebf029611e6d753a4f1bfdbe247a75d954d9ded), [`bacce71`](https://github.com/na2axl/elysiumjs/commit/bacce7146f667a1a46bf474dc0344fb0111ae12a)]:
|
|
8
|
+
- @elysiumjs/core@0.10.0
|
|
9
|
+
- @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
|
+
|
|
3
63
|
## 0.2.0
|
|
4
64
|
|
|
5
65
|
### Minor Changes
|
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}`}}
|
|
@@ -1 +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{
|
|
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};
|
|
@@ -9,5 +9,18 @@ export declare class MigrationRunCommand extends Command {
|
|
|
9
9
|
private tenant;
|
|
10
10
|
private connection;
|
|
11
11
|
private fresh;
|
|
12
|
+
private createSchema;
|
|
12
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;
|
|
13
26
|
}
|
|
@@ -1,15 +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 prompts from"prompts";import{Database}from"../database";import{getMigrationName,planMigrations}from"../migrations";let MigrationRunCommand=class MigrationRunCommand extends Command{static command="migration:run";static description="Runs pending migrations.";tenant="";connection="default";fresh=false;async run(){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();await sql.begin(async(sql)=>{if(this.fresh){await sql`DROP SCHEMA IF EXISTS ${sql(this.tenant)} CASCADE
|
|
2
|
-
SELECT schema_name
|
|
3
|
-
FROM information_schema.schemata
|
|
4
|
-
WHERE schema_name = ${this.tenant}
|
|
5
|
-
) AS schema_exists;`;if(!schema_exists){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){await sql`CREATE SCHEMA ${sql(this.tenant)};`}else{this.error(`Schema ${this.bold(this.tenant)} creation cancelled.`);return process.exit(1)}}await sql`SET search_path TO ${sql(this.tenant)};`;const[{table_exists}]=await sql`SELECT EXISTS (
|
|
6
|
-
SELECT *
|
|
7
|
-
FROM information_schema.tables
|
|
8
|
-
WHERE table_name = 'elysium_migrations'
|
|
9
|
-
AND table_schema = ${this.tenant}
|
|
10
|
-
) AS table_exists;`;if(!table_exists){await sql`CREATE TABLE elysium_migrations (
|
|
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 (
|
|
11
2
|
id SERIAL PRIMARY KEY,
|
|
12
3
|
name TEXT NOT NULL,
|
|
13
4
|
batch INTEGER NOT NULL,
|
|
14
5
|
applied_at TIMESTAMP NOT NULL
|
|
15
|
-
)
|
|
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
3
|
import type { BunSQLDatabase } from 'drizzle-orm/bun-sql/postgres';
|
|
4
|
-
import
|
|
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,14 +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>;
|
|
64
96
|
/**
|
|
65
97
|
* Creates a Drizzle connection from its properties. Used by the `DrizzleModule` to build
|
|
66
98
|
* the `db.connection.{name}` services.
|
|
67
99
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
68
100
|
* @param props The connection properties.
|
|
101
|
+
* @param name The name of the connection, which scopes its query cache.
|
|
69
102
|
* @returns A new connection.
|
|
70
103
|
*/
|
|
71
|
-
createConnection(props: DrizzleConnectionProps): DrizzleConnection;
|
|
104
|
+
createConnection(props: DrizzleConnectionProps, name?: string): DrizzleConnection;
|
|
72
105
|
protected driver: DatabaseDriver<DrizzleConnection, DrizzleConnectionProps>;
|
|
73
106
|
}
|
|
74
107
|
/**
|
|
@@ -76,4 +109,3 @@ declare class DrizzleDatabase extends AbstractDatabase<DrizzleConnection, Drizzl
|
|
|
76
109
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
77
110
|
*/
|
|
78
111
|
export declare const Database: DrizzleDatabase;
|
|
79
|
-
export {};
|
package/dist/database.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
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;
|
package/dist/drizzle.module.d.ts
CHANGED
|
@@ -29,8 +29,8 @@ declare const DrizzleModule_base: (abstract new () => {
|
|
|
29
29
|
};
|
|
30
30
|
/**
|
|
31
31
|
* Declares Drizzle connections as global services named `db.connection.{name}`, plus
|
|
32
|
-
* `db.connection.default` aliasing the configured default,
|
|
33
|
-
* and strategy. Connections are created on first use. Also provides the migration commands.
|
|
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
34
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
35
35
|
*/
|
|
36
36
|
export declare class DrizzleModule extends DrizzleModule_base {
|
package/dist/drizzle.module.js
CHANGED
|
@@ -1 +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,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)})),...alias?[{name:AbstractDatabase.getConnectionName("default"),global:true,factory:(_config,get)=>get(AbstractDatabase.getConnectionName(config.default))}]:[],{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};
|
|
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
|
@@ -2,9 +2,9 @@ export * from 'drizzle-orm';
|
|
|
2
2
|
export * from 'drizzle-orm/pg-core';
|
|
3
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
8
|
export { DrizzleModule, type DrizzleModuleConfig } from './drizzle.module';
|
|
9
9
|
export { DrizzleRepository } from './repository';
|
|
10
|
-
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 +1 @@
|
|
|
1
|
-
export*from"drizzle-orm";export*from"drizzle-orm/pg-core";export{pgTable,getTableBuilders}from"./table";export{Database,DrizzleDatabaseCache}from"./database";export{DrizzleModel,createSchemaFromDrizzle,drizzleAdapter}from"./model";export{DrizzleModule}from"./drizzle.module";export{DrizzleRepository}from"./repository";export{DrizzleSchemaTenancy,DrizzleRLSTenancy,wrapTenantSchema,registerTenantSchema,getTenantSchema,withRLS,
|
|
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";
|
package/dist/migrations.d.ts
CHANGED
|
@@ -31,3 +31,15 @@ export declare const getMigrationName: (path: string) => string;
|
|
|
31
31
|
* @param applied The applied migrations.
|
|
32
32
|
*/
|
|
33
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;
|
package/dist/migrations.js
CHANGED
|
@@ -1 +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};
|
|
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};
|
package/dist/model.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ModelAdapter } from '@elysiumjs/mnemosyne';
|
|
2
|
-
import type {
|
|
2
|
+
import type { PgTable, PgTableWithColumns } from 'drizzle-orm/pg-core';
|
|
3
3
|
/**
|
|
4
4
|
* Drizzle-specific model adapter.
|
|
5
5
|
*
|
|
@@ -41,7 +41,7 @@ export interface DrizzleModelOptions {
|
|
|
41
41
|
* @param options Model configuration options.
|
|
42
42
|
* @returns A model class with Drizzle-backed static metadata.
|
|
43
43
|
*/
|
|
44
|
-
export declare const DrizzleModel: <TTable extends
|
|
44
|
+
export declare const DrizzleModel: <TTable extends PgTable>(table: TTable, options?: DrizzleModelOptions) => {
|
|
45
45
|
new (): {};
|
|
46
46
|
readonly $inferSelect: unknown;
|
|
47
47
|
readonly $inferInsert: unknown;
|
|
@@ -54,8 +54,8 @@ export declare const DrizzleModel: <TTable extends PgTableWithColumns<TableConfi
|
|
|
54
54
|
readonly selectSchema: import("@sinclair/typebox").TObject<{}>;
|
|
55
55
|
readonly supportTenancy: boolean;
|
|
56
56
|
} & {
|
|
57
|
-
readonly $inferSelect: TTable["
|
|
58
|
-
readonly $inferInsert: TTable["
|
|
59
|
-
readonly $inferUpdate: Partial<TTable["
|
|
57
|
+
readonly $inferSelect: { [Key in keyof TTable["_"]["columns"] & string]: TTable["_"]["columns"][Key]["_"]["notNull"] extends true ? TTable["_"]["columns"][Key]["_"]["data"] : TTable["_"]["columns"][Key]["_"]["data"] | null; } extends infer T ? { [K in keyof T]: T[K]; } : never;
|
|
58
|
+
readonly $inferInsert: { [Key in keyof TTable["_"]["columns"] & string as import("drizzle-orm").RequiredKeyOnly<Key, TTable["_"]["columns"][Key]>]: TTable["_"]["columns"][Key]["_"]["notNull"] extends true ? TTable["_"]["columns"][Key]["_"]["data"] : TTable["_"]["columns"][Key]["_"]["data"] | null; } & { [Key in keyof TTable["_"]["columns"] & string as import("drizzle-orm").OptionalKeyOnly<Key, TTable["_"]["columns"][Key], false>]?: (TTable["_"]["columns"][Key]["_"]["notNull"] extends true ? TTable["_"]["columns"][Key]["_"]["data"] : TTable["_"]["columns"][Key]["_"]["data"] | null) | undefined; } extends infer T_1 ? { [K in keyof T_1]: T_1[K]; } : never;
|
|
59
|
+
readonly $inferUpdate: Partial<{ [Key in keyof TTable["_"]["columns"] & string as import("drizzle-orm").RequiredKeyOnly<Key, TTable["_"]["columns"][Key]>]: TTable["_"]["columns"][Key]["_"]["notNull"] extends true ? TTable["_"]["columns"][Key]["_"]["data"] : TTable["_"]["columns"][Key]["_"]["data"] | null; } & { [Key in keyof TTable["_"]["columns"] & string as import("drizzle-orm").OptionalKeyOnly<Key, TTable["_"]["columns"][Key], false>]?: (TTable["_"]["columns"][Key]["_"]["notNull"] extends true ? TTable["_"]["columns"][Key]["_"]["data"] : TTable["_"]["columns"][Key]["_"]["data"] | null) | undefined; } extends infer T_1 ? { [K in keyof T_1]: T_1[K]; } : never>;
|
|
60
60
|
readonly table: TTable;
|
|
61
61
|
};
|
package/dist/repository.d.ts
CHANGED
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
import type { IdType, ModelClass } from '@elysiumjs/mnemosyne';
|
|
2
|
-
import type {
|
|
2
|
+
import type { PgColumn, PgTable } from 'drizzle-orm/pg-core';
|
|
3
3
|
import type { DrizzleConnection } from './database';
|
|
4
4
|
/**
|
|
5
5
|
* Drizzle-specific repository mixin with concrete CRUD operations.
|
|
6
6
|
*
|
|
7
7
|
* Extends the abstract repository with Drizzle query builder calls
|
|
8
|
-
* for all standard CRUD methods.
|
|
8
|
+
* for all standard CRUD methods. The model table must have an `id` column, used as the
|
|
9
|
+
* primary key by `find`, `update`, `delete` and `exists`.
|
|
9
10
|
*
|
|
10
11
|
* @author Axel Nana <axel.nana@workbud.com>
|
|
11
12
|
* @template TModel The model class type.
|
|
12
13
|
* @param model The model class to create the repository for.
|
|
13
14
|
* @returns A concrete repository class with CRUD methods.
|
|
14
15
|
*/
|
|
15
|
-
export declare const DrizzleRepository: <TModel extends ModelClass<any, any, any,
|
|
16
|
+
export declare const DrizzleRepository: <TModel extends ModelClass<any, any, any, PgTable & {
|
|
17
|
+
id: PgColumn;
|
|
18
|
+
}>>(model: TModel) => {
|
|
16
19
|
new (): {
|
|
20
|
+
/**
|
|
21
|
+
* Runs a database operation on the repository connection.
|
|
22
|
+
*
|
|
23
|
+
* In RLS tenancy mode, an operation that runs outside a transaction is wrapped in one
|
|
24
|
+
* scoped to the current tenant (through the tenancy strategy's `withIsolation`), so RLS
|
|
25
|
+
* policies apply to it. Inside an open transaction, the operation joins it when the
|
|
26
|
+
* transaction is bound to the current tenant, and throws `TenantMismatchError` otherwise
|
|
27
|
+
* (see `DrizzleRLSTenancy.withIsolation`).
|
|
28
|
+
* @param operation The operation to run with the connection to use.
|
|
29
|
+
* @returns The return value of the operation.
|
|
30
|
+
*/
|
|
31
|
+
withConnection<T>(operation: (db: DrizzleConnection) => Promise<T>): Promise<T>;
|
|
17
32
|
all(): Promise<TModel["$inferSelect"][]>;
|
|
18
33
|
paginate(count: number, page?: number): Promise<{
|
|
19
34
|
page: number;
|
|
@@ -23,9 +38,9 @@ export declare const DrizzleRepository: <TModel extends ModelClass<any, any, any
|
|
|
23
38
|
find(id: IdType): Promise<TModel["$inferSelect"] | null>;
|
|
24
39
|
findBy<TKey extends keyof TModel["$inferSelect"]>(column: TKey, value: TModel["$inferSelect"][TKey]): Promise<TModel["$inferSelect"] | null>;
|
|
25
40
|
insert(data: TModel["$inferInsert"]): Promise<TModel["$inferSelect"]>;
|
|
26
|
-
update(id: IdType, data: TModel["$inferUpdate"]): Promise<TModel["$inferSelect"]>;
|
|
41
|
+
update(id: IdType, data: TModel["$inferUpdate"]): Promise<TModel["$inferSelect"] | null>;
|
|
27
42
|
updateAll(data: TModel["$inferUpdate"]): Promise<TModel["$inferSelect"][]>;
|
|
28
|
-
delete(id: IdType): Promise<TModel["$inferSelect"]>;
|
|
43
|
+
delete(id: IdType): Promise<TModel["$inferSelect"] | null>;
|
|
29
44
|
deleteAll(): Promise<TModel["$inferSelect"][]>;
|
|
30
45
|
exists(id: IdType): Promise<boolean>;
|
|
31
46
|
readonly db: DrizzleConnection;
|
package/dist/repository.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{AbstractRepository}from"@elysiumjs/mnemosyne";import{eq}from"drizzle-orm";import{Database}from"./database";export const DrizzleRepository=(model)=>{class R extends AbstractRepository(model,Database){async
|
|
1
|
+
import{AbstractRepository,getCurrentTenant,getTenancyConfig,getTenancyStrategy}from"@elysiumjs/mnemosyne";import{eq}from"drizzle-orm";import{Database}from"./database";import{DrizzleRLSTenancy}from"./tenancy";export const DrizzleRepository=(model)=>{const table=()=>model.table;class R extends AbstractRepository(model,Database){async withConnection(operation){const name=this.constructor.connection;if(getTenancyConfig().mode!=="rls")return operation(this.db);const strategy=getTenancyStrategy()??new DrizzleRLSTenancy;return strategy.withIsolation(Database.getBaseConnection(name),getCurrentTenant()??"public",()=>operation(this.db))}async all(){return this.withConnection((db)=>db.select().from(table()))}async paginate(count,page=1){const offset=Math.max(page-1,0)*count;return this.withConnection(async(db)=>({page,total:await db.$count(table()),data:await db.select().from(table()).offset(offset).limit(count)}))}async find(id){const[row]=await this.withConnection((db)=>db.select().from(table()).where(eq(table().id,id)));return row??null}async findBy(column,value){const[row]=await this.withConnection((db)=>db.select().from(table()).where(eq(table()[column],value)));return row??null}async insert(data){const[row]=await this.withConnection((db)=>db.insert(table()).values(data).returning());return row}async update(id,data){const[row]=await this.withConnection((db)=>db.update(table()).set(data).where(eq(table().id,id)).returning());return row??null}async updateAll(data){return this.withConnection((db)=>db.update(table()).set(data).returning())}async delete(id){const[row]=await this.withConnection((db)=>db.delete(table()).where(eq(table().id,id)).returning());return row??null}async deleteAll(){return this.withConnection((db)=>db.delete(table()).returning())}async exists(id){const count=await this.withConnection((db)=>db.$count(table(),eq(table().id,id)));return count>0}}return R};
|