@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cache.d.ts +60 -0
  3. package/dist/cache.js +1 -0
  4. package/dist/commands/migration_generate.command.js +1 -1
  5. package/dist/commands/migration_run.command.d.ts +13 -0
  6. package/dist/commands/migration_run.command.js +6 -11
  7. package/dist/commands/project.d.ts +11 -0
  8. package/dist/commands/project.js +1 -0
  9. package/dist/database.d.ts +55 -23
  10. package/dist/database.js +1 -1
  11. package/dist/drizzle.module.d.ts +2 -2
  12. package/dist/drizzle.module.js +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/migrations.d.ts +12 -0
  16. package/dist/migrations.js +2 -1
  17. package/dist/model.d.ts +5 -5
  18. package/dist/repository.d.ts +20 -5
  19. package/dist/repository.js +1 -1
  20. package/dist/tenancy.d.ts +25 -8
  21. package/dist/tenancy.js +1 -1
  22. package/package.json +19 -9
  23. package/.omc/state/agent-replay-3920b579-6d8e-4f5f-924a-319202936685.jsonl +0 -7
  24. package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/last-tool-error-state.json +0 -7
  25. package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/mission-state.json +0 -93
  26. package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/pre-tool-advisory-throttle.json +0 -18
  27. package/.omc/state/sessions/3920b579-6d8e-4f5f-924a-319202936685/subagent-tracking-state.json +0 -61
  28. package/.turbo/turbo-build.log +0 -3
  29. package/scripts/minify.ts +0 -10
  30. package/src/commands/migration_generate.command.ts +0 -101
  31. package/src/commands/migration_run.command.ts +0 -160
  32. package/src/database.ts +0 -205
  33. package/src/drizzle.module.ts +0 -97
  34. package/src/index.ts +0 -47
  35. package/src/migrations.ts +0 -100
  36. package/src/model.ts +0 -178
  37. package/src/repository.ts +0 -113
  38. package/src/table.ts +0 -70
  39. package/src/tenancy.ts +0 -249
  40. package/tests/drizzle.module.test.ts +0 -201
  41. package/tests/migrations.test.ts +0 -123
  42. package/tests/model.test.ts +0 -362
  43. package/tests/setup.ts +0 -0
  44. package/tests/table.test.ts +0 -72
  45. package/tests/tenancy.test.ts +0 -212
  46. 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
@@ -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{existsSync}from"node:fs";import{exists}from"node:fs/promises";import{dirname,join}from"node:path";import{Command,CommandArgumentType}from"@elysiumjs/core";import prompts from"prompts";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};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();let schemaPath=`${projectRoot}/src/database/schemas`;if(await exists(`${projectRoot}/src/database/schema.ts`)){schemaPath=`${projectRoot}/src/database/schema.ts`}await Bun.$`bunx --bun drizzle-kit generate --dialect postgresql --schema ${schemaPath} --out ${projectRoot}/src/database/migrations --name ${this.name}`;const sqlFiles=new Bun.Glob(`${projectRoot}/src/database/migrations/**/*.sql`).scan();for await(const path of sqlFiles){const file=Bun.file(path);const content=await file.text();await file.write(content.replace(/"public"\./gm,""))}}};__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};
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;`;await sql`CREATE SCHEMA ${sql(this.tenant)};`}const[{schema_exists}]=await sql`SELECT EXISTS (
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
- );`}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(`./src/database/migrations/**/*.sql`).scan({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.`)});await sql.close()}};__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);MigrationRunCommand=__decorate([Command.register()],MigrationRunCommand);export{MigrationRunCommand};
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");
@@ -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 type { CacheConfig } from 'drizzle-orm/cache/core/types';
4
+ import { ElysiumError } from '@elysiumjs/core';
5
5
  import { AbstractDatabase } from '@elysiumjs/mnemosyne';
6
- import { Table } from 'drizzle-orm';
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
- * Drizzle query cache backed by the framework's cache storage.
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 DrizzleDatabaseCache extends DrizzleCache {
42
- #private;
43
- private readonly cacheStrategy;
44
- private readonly globalTtl;
45
- private usedTablesPerKey;
46
- constructor(cacheStrategy?: 'explicit' | 'all', globalTtl?: number, storageEngine?: 'redis' | 'memory');
47
- strategy(): 'explicit' | 'all';
48
- get(key: string): Promise<any[] | undefined>;
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{Service,ServiceNotReadyError}from"@elysiumjs/core";import{AbstractDatabase,createDatabaseCacheStorage}from"@elysiumjs/mnemosyne";import{getTableName,is,Table}from"drizzle-orm";import{drizzle}from"drizzle-orm/bun-sql";import{Cache as DrizzleCache}from"drizzle-orm/cache/core";export class DrizzleDatabaseCache extends DrizzleCache{cacheStrategy;globalTtl;#storageEngine;#cache;usedTablesPerKey={};constructor(cacheStrategy="explicit",globalTtl=1,storageEngine="redis"){super();this.cacheStrategy=cacheStrategy;this.globalTtl=globalTtl;this.#storageEngine=storageEngine}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 get(key){return await(await this.#storage()).get(key)??undefined}async put(key,response,tables,_isTag,config){await(await this.#storage()).set(key,response,(config?.ex??this.globalTtl)*1000);for(const table of tables){const keys=this.usedTablesPerKey[table];if(keys===undefined){this.usedTablesPerKey[table]=[key]}else{keys.push(key)}}}async onMutate(params){const tagsArray=params.tags?Array.isArray(params.tags)?params.tags:[params.tags]:[];const tablesArray=params.tables?Array.isArray(params.tables)?params.tables:[params.tables]:[];const keysToDelete=new Set;for(const table of tablesArray){const tableName=is(table,Table)?getTableName(table):table;const keys=this.usedTablesPerKey[tableName]??[];for(const key of keys)keysToDelete.add(key)}if(keysToDelete.size>0||tagsArray.length>0){const cache=await this.#storage();await cache.mdel(tagsArray);await cache.mdel([...keysToDelete]);for(const table of tablesArray){const tableName=is(table,Table)?getTableName(table):table;this.usedTablesPerKey[tableName]=[]}}}}class DrizzleDatabase extends AbstractDatabase{createConnection(props){return this.driver.createConnection(props)}driver={createConnection(config){const cache=config.cache===undefined?undefined:config.cache===true?new DrizzleDatabaseCache:config.cache.enabled!==false?new DrizzleDatabaseCache(config.cache.strategy,config.cache.ttl,config.cache.storage):undefined;return drizzle({...config,cache})},withTransaction(connection,callback){return connection.transaction((tx)=>callback(tx))},getRawClient(connection){return connection.$client}}}export const Database=new DrizzleDatabase;
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;
@@ -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, and registers the tenancy config
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 {
@@ -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, setConnectionTenant, getSessionTenant, createRLSPolicy } from './tenancy';
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,setConnectionTenant,getSessionTenant,createRLSPolicy}from"./tenancy";
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";
@@ -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;
@@ -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 { PgTableWithColumns, TableConfig } from 'drizzle-orm/pg-core';
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 PgTableWithColumns<TableConfig>>(table: TTable, options?: DrizzleModelOptions) => {
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["$inferSelect"];
58
- readonly $inferInsert: TTable["$inferInsert"];
59
- readonly $inferUpdate: Partial<TTable["$inferInsert"]>;
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
  };
@@ -1,19 +1,34 @@
1
1
  import type { IdType, ModelClass } from '@elysiumjs/mnemosyne';
2
- import type { PgTableWithColumns } from 'drizzle-orm/pg-core';
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, PgTableWithColumns<any>>>(model: TModel) => {
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;
@@ -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 all(){return await this.db.select().from(model.table)}async paginate(count,page=1){const total=await this.db.$count(model.table);const offset=Math.max(page-1,0)*count;return{page,total,data:await this.db.select().from(model.table).offset(offset).limit(count)}}async find(id){const[row]=await this.db.select().from(model.table).where(eq(model.table.id,id));return row??null}async findBy(column,value){const[row]=await this.db.select().from(model.table).where(eq(model.table[column],value));return row??null}async insert(data){const[row]=await this.db.insert(model.table).values(data).returning();return row}async update(id,data){const[row]=await this.db.update(model.table).set(data).where(eq(model.table.id,id)).returning();return row}async updateAll(data){return await this.db.update(model.table).set(data).returning()}async delete(id){const[row]=await this.db.delete(model.table).where(eq(model.table.id,id)).returning();return row}async deleteAll(){return await this.db.delete(model.table).returning()}async exists(id){const count=await this.db.$count(model.table,eq(model.table.id,id));return count>0}}return R};
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};