@elysiumjs/mnemosyne-drizzle 0.3.1 → 0.4.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 CHANGED
@@ -1,5 +1,66 @@
1
1
  # @elysiumjs/mnemosyne-drizzle
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`4cd1ef2`](https://github.com/na2axl/elysiumjs/commit/4cd1ef217826164e69cd641ca99f74c6f5a7ebe7)]:
8
+ - @elysiumjs/mnemosyne@0.5.0
9
+ - @elysiumjs/core@0.11.1
10
+ - @elysiumjs/runtime-workers@0.1.0
11
+
12
+ ## 0.4.0
13
+
14
+ ### Minor Changes
15
+
16
+ - [#15](https://github.com/na2axl/elysiumjs/pull/15) [`fbcc7da`](https://github.com/na2axl/elysiumjs/commit/fbcc7dad127db29da42e7a4a5c2124966731cd88) Thanks [@na2axl](https://github.com/na2axl)! - Resources, queries, units of work and rate limits.
17
+
18
+ Breaking:
19
+
20
+ - A route with a `response` schema serializes its handler result through it: fields the schema does not declare are dropped, a `Date` becomes an ISO string (`YYYY-MM-DD` for `format: 'date'`), and an object with `toJSON()` becomes what it returns. Set `serialize: false` on a route to send the result as it is.
21
+
22
+ Added:
23
+
24
+ - `Resource.hidden(schema)` marks a field that never reaches responses nor the OpenAPI document. `Resource.serialize()` and `Resource.project()` are exported for use outside routes.
25
+ - Sparse fieldsets: `?fields=id,title` keeps only those fields of an object, of each item of an array, or of each item of a `data` array. An unknown or hidden field answers a 400 problem.
26
+ - Rate limits with `@RateLimit({ window, max, key?, store?, name? })` on routes and controllers (`@RateLimit(false)` turns one off), and a default limit and store in the app's `rateLimit` option, with fixed windows. Responses carry `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy`; a client over the limit gets a 429 problem with `Retry-After`. Counts are kept by a `RateLimitStore`: `MemoryRateLimitStore` (core), `RedisRateLimitStore` (`@elysiumjs/redis`) or `WorkersRateLimitStore` over a Cloudflare rate limiting binding (`@elysiumjs/runtime-workers`), which only decides whether a hit is over the limit, so its responses carry no `RateLimit-Reset`.
27
+ - `ModelResource(model, { hidden, pick, omit, extend })` (`@elysiumjs/mnemosyne`) builds a response schema from a model.
28
+ - `Query.define(model, spec)` declares the filters (`filter[field][op]=value`), sorts, page size and cursor a list accepts; `Query.schema()` gives its query schema, `Query.parse()` turns a request query into a `ParsedQuery` and answers a 400 problem with a pointer per mistake, and `Query.page()` / `Query.pageSchema()` build cursor pages (`{ data, nextCursor }`).
29
+ - `DrizzleRepository.list(query)` and `count(query)` run a `ParsedQuery` with keyset pagination (`@elysiumjs/mnemosyne-drizzle`), and `compileQuery()` gives its `where` and `orderBy`.
30
+ - Units of work: `UnitOfWork.run()` and the `@Transactional()` method decorator run work in one transaction, and `UnitOfWork.afterCommit()` / `afterRollback()` defer work, such as sending a message, until the outermost transaction has ended. `transactional` routes open a unit of work too.
31
+
32
+ - [#16](https://github.com/na2axl/elysiumjs/pull/16) [`ac96e55`](https://github.com/na2axl/elysiumjs/commit/ac96e55874b0ffe350b287c4278cd85afecb7576) Thanks [@na2axl](https://github.com/na2axl)! - Redis, databases, logs, jobs and builds on Bun, Node.js and Cloudflare Workers.
33
+
34
+ Breaking:
35
+
36
+ - The level shortcuts of `InteractsWithConsole` (`info`, `success`, `warning`, `error` and `debug`) write a tag (`[I]`, `[S]`, `[W]`, `[E]` and `[D]`, in color) before the message, which `InteractsWithConsole.useTags()` changes. `trace()` writes to stderr.
37
+ - Outside Bun, a Drizzle connection needs a `driver`. Without one, the `connection` and `client` props are a Bun SQL connection, which is only available on Bun; elsewhere the connection throws `MissingDriverError`, which names the drivers.
38
+
39
+ Added:
40
+
41
+ - Redis adapters (`@elysiumjs/redis`): `ioredis(create)` plugs an ioredis client into the Redis port on Node.js, and `upstash({ token, url?, fetch? })` talks to Upstash over its REST API, for Workers (no pub/sub). `bunRedis` explains which adapter to use when it runs outside Bun.
42
+ - Drizzle drivers (`@elysiumjs/mnemosyne-drizzle`): a `DrizzleDriver` port set with the `driver` prop of a connection, and drivers on subpaths so that only the chosen database client is imported: `/bun-sql` (`bunSql()`), `/postgres-js` (`postgresJs()`), `/node-postgres` (`nodePostgres()`) and `/hyperdrive` (`hyperdrive({ binding, driver })`, a client per Worker invocation over a Cloudflare Hyperdrive binding). `DrizzleModule` closes connections through their driver, and `migration:run` runs on any driver.
43
+ - `InteractsWithConsole.write()` and `Runtime.write()` take the level of the line (`OutputLevel`), which the level shortcuts pass. On Cloudflare Workers, a line is written with the console method of its level, so Workers Logs keep it.
44
+ - `JsonConsoleLogger` (`@elysiumjs/hermes`): JSON lines through the console of the runtime (warnings and errors on stderr), with levels, error serialization and `child()`. `HermesModule.with({ transport: 'console' | 'pino' })` picks it, and it is the default on Workers. `RequestLogInterceptor` writes one structured line per request: method, route, status, duration, client address and request id.
45
+ - Scheduled jobs (`@elysiumjs/heracles`): `@Schedule(cron, { timezone, args })` on a job class, a five-field cron parser (`Cron`, with lists, ranges, steps and names), and a `Scheduler` that dispatches the jobs on time on Bun and Node.js, taking a Redis lock per tick when a Redis connection is set. The `heracles:schedule` command runs it.
46
+ - `@elysiumjs/heracles-workers`: `CloudflareQueueTransport` sends jobs to a Cloudflare Queue (dispatch delays become `delaySeconds`), and `HeraclesWorkersModule` consumes them in the Worker's `queue` handler (ack on success, retry with a delay up to the job's retries) and dispatches the `@Schedule()` jobs whose expression is the cron of a trigger.
47
+ - Queue handlers (`@elysiumjs/runtime-workers`) can settle a message themselves with `ack()` or `retry({ delaySeconds })`; the messages they leave are acked when no handler failed on them.
48
+ - `styx build --target node` (`@elysiumjs/hephaestus`) bundles the app into one ES module for Node.js, with decorator metadata emitted at build time, run with `node dist/server.js serve`. `styx build --target workers` runs from the CLI, resolves packages to their browser entry, and leaves Node.js modules imported by bare names to workerd (`nodejs_compat`).
49
+ - `styx create` (`@elysiumjs/styx`) takes `--runtime bun|node|workers`, `--database`, `--cache` and `--package-manager bun|npm|pnpm`. Each runtime gets its entry, tsconfig types, scripts and drivers: postgres.js and ioredis on Node.js; Hyperdrive, Upstash, console logs and a `wrangler.jsonc` on Workers.
50
+
51
+ ### Patch Changes
52
+
53
+ - [#11](https://github.com/na2axl/elysiumjs/pull/11) [`632d77b`](https://github.com/na2axl/elysiumjs/commit/632d77b561fc43ade605fb69b06ac1b9b9a2ffc4) Thanks [@na2axl](https://github.com/na2axl)! - Services are identified by their class, or by the name given to `@Service.register({ name })`. Class names are never used as identifiers, so services resolve the same way in minified builds; `bun run check:minified` in core bundles a minified app and checks that every service resolves. `@Service.inject()` without an argument injects the class of the parameter type, and throws when the type is unknown. Factory services in a module's `services` take a class or a name in `name`.
54
+
55
+ `ServiceScope.REQUEST` gives one instance per HTTP request, shared by the request's middlewares, controller and handler. `Service.runInRequestScope(fn)` opens a request scope outside a route. Resolving a request-scoped service outside a request throws `OutsideRequestScopeError`, and a singleton (or a server-scoped controller) depending on one throws `ScopeMismatchError`.
56
+
57
+ `HermesModule` registers the logger by the `HermesLogger` class and as `logger`.
58
+
59
+ - Updated dependencies [[`6bc48fe`](https://github.com/na2axl/elysiumjs/commit/6bc48febd51f7090c582e73f349eaeff7261241c), [`e9c3d98`](https://github.com/na2axl/elysiumjs/commit/e9c3d98dd2714b69100215f7c8a80046da463148), [`557a3ca`](https://github.com/na2axl/elysiumjs/commit/557a3cab7af8053a8bab9944e492e5022d91aa82), [`ccc3f5b`](https://github.com/na2axl/elysiumjs/commit/ccc3f5bb904d1bf52d8c72ebf7ea8d10f617a8c4), [`6789879`](https://github.com/na2axl/elysiumjs/commit/678987976ecfd46b534b65276e0de3f448977562), [`87299fd`](https://github.com/na2axl/elysiumjs/commit/87299fd9da82d1a408ced1891c0c3d42991444e5), [`bee66bd`](https://github.com/na2axl/elysiumjs/commit/bee66bdc874b8f23fea356f7d3adf7555a48ca64), [`0057b67`](https://github.com/na2axl/elysiumjs/commit/0057b67df0e3abecab8a9accff6b55a6a64c21df), [`1738567`](https://github.com/na2axl/elysiumjs/commit/17385674c14991f0ddb27eb6ee967afbcebcc307), [`632d77b`](https://github.com/na2axl/elysiumjs/commit/632d77b561fc43ade605fb69b06ac1b9b9a2ffc4), [`1ea417e`](https://github.com/na2axl/elysiumjs/commit/1ea417e3583250bc2f975d795f398b7ff736115d), [`0956186`](https://github.com/na2axl/elysiumjs/commit/0956186db0ca5a83c0a6636aeb2a4c81a0033de4), [`fbcc7da`](https://github.com/na2axl/elysiumjs/commit/fbcc7dad127db29da42e7a4a5c2124966731cd88), [`ac96e55`](https://github.com/na2axl/elysiumjs/commit/ac96e55874b0ffe350b287c4278cd85afecb7576), [`98a16aa`](https://github.com/na2axl/elysiumjs/commit/98a16aae889d46f7a9fb5573188f4cbe3629dd3b)]:
60
+ - @elysiumjs/core@0.11.0
61
+ - @elysiumjs/runtime-workers@0.1.0
62
+ - @elysiumjs/mnemosyne@0.4.0
63
+
3
64
  ## 0.3.1
4
65
 
5
66
  ### Patch Changes
@@ -1,33 +1,52 @@
1
1
  import type { DatabaseCacheConfig, DatabaseDriver } from '@elysiumjs/mnemosyne';
2
2
  import type { DrizzleConfig } from 'drizzle-orm';
3
- import type { BunSQLDatabase } from 'drizzle-orm/bun-sql/postgres';
3
+ import type { PgAsyncDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
4
+ import type { DrizzleDriver } from './driver';
4
5
  import { ElysiumError } from '@elysiumjs/core';
5
6
  import { AbstractDatabase } from '@elysiumjs/mnemosyne';
6
7
  export { DEFAULT_CACHE_TTL, DrizzleDatabaseCache } from './cache';
7
8
  /**
8
9
  * Connection configuration for Drizzle ORM.
9
10
  *
10
- * Extends `DrizzleConfig` with connection details and optional caching.
11
+ * Extends `DrizzleConfig` with the driver and optional caching. Without `driver`, `connection`
12
+ * or `client` connect with Bun SQL, on Bun only.
11
13
  *
12
14
  * @author Axel Nana <axel.nana@workbud.com>
13
15
  * @template TSchema The Drizzle schema type.
14
16
  */
15
17
  export type DrizzleConnectionProps<TSchema extends Record<string, unknown> = Record<string, never>> = Omit<DrizzleConfig<TSchema>, 'cache'> & ({
18
+ driver: DrizzleDriver;
19
+ } | {
16
20
  connection: string | ({
17
21
  url?: string;
18
- } & Bun.SQLOptions);
22
+ } & Record<string, unknown>);
19
23
  } | {
20
- client: Bun.SQL;
24
+ client: unknown;
21
25
  }) & {
22
26
  cache?: true | DatabaseCacheConfig;
23
27
  };
24
28
  /**
25
- * A Drizzle database connection backed by Bun's SQL driver.
29
+ * A Drizzle database connection, whatever its driver.
26
30
  * @author Axel Nana <axel.nana@workbud.com>
27
31
  */
28
- export type DrizzleConnection = BunSQLDatabase<Record<string, never>> & {
29
- $client: Bun.SQL;
32
+ export type DrizzleConnection = PgAsyncDatabase<DrizzleQueryResultHKT, any> & {
33
+ $client: any;
30
34
  };
35
+ /**
36
+ * The query results of a {@link DrizzleConnection}: rows, for `returning()` and selects. What
37
+ * `execute()` resolves to depends on the driver.
38
+ * @author Axel Nana <axel.nana@workbud.com>
39
+ */
40
+ export interface DrizzleQueryResultHKT extends PgQueryResultHKT {
41
+ type: this['row'][];
42
+ }
43
+ /**
44
+ * Closes the client of a connection through its driver.
45
+ * @internal
46
+ * @author Axel Nana <axel.nana@workbud.com>
47
+ * @param connection The connection.
48
+ */
49
+ export declare const closeConnection: (connection: DrizzleConnection) => Promise<void>;
31
50
  /**
32
51
  * Thrown in RLS mode when work for one tenant would join a transaction bound to another.
33
52
  *
@@ -65,7 +84,7 @@ export declare const assertTransactionTenant: (tx: DrizzleConnection, tenant: st
65
84
  * Drizzle-specific database manager.
66
85
  *
67
86
  * Implements the abstract database class with a driver that creates
68
- * Drizzle connections using Bun's built-in SQL client.
87
+ * Drizzle connections through the {@link DrizzleDriver} of each connection.
69
88
  *
70
89
  * @author Axel Nana <axel.nana@workbud.com>
71
90
  */
@@ -0,0 +1,39 @@
1
+ import type { DrizzleConfig } from 'drizzle-orm';
2
+ import type { DrizzleDatabaseCache } from './cache';
3
+ import type { DrizzleConnection } from './database';
4
+ import { ElysiumError } from '@elysiumjs/core';
5
+ /**
6
+ * What a driver connects with: the Drizzle options of the connection and its query cache.
7
+ * @author Axel Nana <axel.nana@workbud.com>
8
+ */
9
+ export type DrizzleDriverConfig = Omit<DrizzleConfig<Record<string, unknown>>, 'cache'> & {
10
+ cache?: DrizzleDatabaseCache;
11
+ };
12
+ /**
13
+ * Connects Drizzle to PostgreSQL through a client: Bun SQL (`/bun-sql`), postgres.js
14
+ * (`/postgres-js`), node-postgres (`/node-postgres`) or Cloudflare Hyperdrive (`/hyperdrive`).
15
+ * Implement it to plug in another client.
16
+ * @author Axel Nana <axel.nana@workbud.com>
17
+ */
18
+ export interface DrizzleDriver {
19
+ /** The name of the driver, for errors and logs. */
20
+ readonly name: string;
21
+ /**
22
+ * Creates the Drizzle database of a connection.
23
+ * @param config The Drizzle options and the query cache.
24
+ */
25
+ connect(config: DrizzleDriverConfig): DrizzleConnection;
26
+ /**
27
+ * Closes the client of a database the driver created.
28
+ * @param connection The database.
29
+ */
30
+ close(connection: DrizzleConnection): Promise<void> | void;
31
+ }
32
+ /**
33
+ * Raised when a connection sets no driver outside Bun, where the default Bun SQL driver is not
34
+ * available.
35
+ * @author Axel Nana <axel.nana@workbud.com>
36
+ */
37
+ export declare class MissingDriverError extends ElysiumError {
38
+ constructor(name: string);
39
+ }
@@ -0,0 +1,25 @@
1
+ import type { SQL } from 'bun';
2
+ import type { DrizzleDriver } from '../driver';
3
+ /**
4
+ * Options of {@link bunSql}: a connection URL or options, or a client.
5
+ * @author Axel Nana <axel.nana@workbud.com>
6
+ */
7
+ export type BunSqlDriverOptions = {
8
+ connection: string | ({
9
+ url?: string;
10
+ } & SQL.Options);
11
+ } | {
12
+ client: SQL;
13
+ };
14
+ /**
15
+ * Connects with Bun's built-in SQL client. Runs on Bun only.
16
+ * @author Axel Nana <axel.nana@workbud.com>
17
+ * @param options The connection URL or options, or a client.
18
+ * @example
19
+ * ```ts
20
+ * import { bunSql } from '@elysiumjs/mnemosyne-drizzle/bun-sql';
21
+ *
22
+ * DrizzleModule.with({ connections: { default: { driver: bunSql({ connection: url }) } } });
23
+ * ```
24
+ */
25
+ export declare const bunSql: (options: BunSqlDriverOptions) => DrizzleDriver;
@@ -0,0 +1 @@
1
+ import{drizzle}from"drizzle-orm/bun-sql";var bunSql=(options)=>({name:"bun-sql",connect:(config)=>drizzle({...config,...options}),close:async(connection)=>{await connection.$client.close()}});export{bunSql};
@@ -0,0 +1,46 @@
1
+ import type { DrizzleDriver } from '../driver';
2
+ /**
3
+ * A Hyperdrive binding, as Cloudflare Workers pass it.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export type HyperdriveBinding = {
7
+ readonly connectionString: string;
8
+ };
9
+ /**
10
+ * Options of {@link hyperdrive}.
11
+ * @author Axel Nana <axel.nana@workbud.com>
12
+ */
13
+ export type HyperdriveDriverOptions = {
14
+ /** The name of the Hyperdrive binding, as declared in the wrangler config. */
15
+ binding: string;
16
+ /**
17
+ * The driver that connects to the connection string of the binding, such as
18
+ * `(url) => postgresJs({ connection: { url, max: 5, fetch_types: false } })`.
19
+ */
20
+ driver: (connectionString: string) => DrizzleDriver;
21
+ };
22
+ /**
23
+ * Connects through a Cloudflare Hyperdrive binding. A Worker cannot reuse a socket opened in
24
+ * another invocation, so the database opens a client per invocation (request, queue batch or
25
+ * scheduled event) on first use. The runtime closes its sockets when the invocation ends, and
26
+ * Hyperdrive pools the connections to the database.
27
+ * @author Axel Nana <axel.nana@workbud.com>
28
+ * @param options The binding name and the client driver.
29
+ * @example
30
+ * ```ts
31
+ * import { hyperdrive } from '@elysiumjs/mnemosyne-drizzle/hyperdrive';
32
+ * import { postgresJs } from '@elysiumjs/mnemosyne-drizzle/postgres-js';
33
+ *
34
+ * DrizzleModule.with({
35
+ * connections: {
36
+ * default: {
37
+ * driver: hyperdrive({
38
+ * binding: 'HYPERDRIVE',
39
+ * driver: (url) => postgresJs({ connection: { url, max: 5, fetch_types: false } })
40
+ * })
41
+ * }
42
+ * }
43
+ * });
44
+ * ```
45
+ */
46
+ export declare const hyperdrive: (options: HyperdriveDriverOptions) => DrizzleDriver;
@@ -0,0 +1 @@
1
+ import{Workers}from"@elysiumjs/runtime-workers";var hyperdrive=(options)=>({name:"hyperdrive",connect:(config)=>invocationDatabase(options,config),close:()=>{}}),invocationDatabase=(options,config)=>{let databases=new WeakMap,current=()=>{let context=Workers.context(),database=databases.get(context);if(!database){let{connectionString}=Workers.binding(options.binding);database=options.driver(connectionString).connect(config),databases.set(context,database)}return database};return new Proxy({},{get(target,property){if(Object.hasOwn(target,property))return Reflect.get(target,property);let database=current(),value=Reflect.get(database,property,database);return typeof value==="function"?value.bind(database):value},has(target,property){return Object.hasOwn(target,property)||property in current()},getPrototypeOf(){return Object.getPrototypeOf(current())}})};export{hyperdrive};
@@ -0,0 +1,23 @@
1
+ import type { Pool, PoolConfig } from 'pg';
2
+ import type { DrizzleDriver } from '../driver';
3
+ /**
4
+ * Options of {@link nodePostgres}: a connection URL or pool options, or a pool.
5
+ * @author Axel Nana <axel.nana@workbud.com>
6
+ */
7
+ export type NodePostgresDriverOptions = {
8
+ connection: string | PoolConfig;
9
+ } | {
10
+ client: Pool;
11
+ };
12
+ /**
13
+ * Connects with node-postgres (`pg`), on Node.js and Bun.
14
+ * @author Axel Nana <axel.nana@workbud.com>
15
+ * @param options The connection URL or pool options, or a pool.
16
+ * @example
17
+ * ```ts
18
+ * import { nodePostgres } from '@elysiumjs/mnemosyne-drizzle/node-postgres';
19
+ *
20
+ * DrizzleModule.with({ connections: { default: { driver: nodePostgres({ connection: url }) } } });
21
+ * ```
22
+ */
23
+ export declare const nodePostgres: (options: NodePostgresDriverOptions) => DrizzleDriver;
@@ -0,0 +1 @@
1
+ import{drizzle}from"drizzle-orm/node-postgres";var nodePostgres=(options)=>({name:"node-postgres",connect:(config)=>drizzle({...config,...options}),close:async(connection)=>{await connection.$client.end()}});export{nodePostgres};
@@ -0,0 +1,25 @@
1
+ import type { Options, PostgresType, Sql } from 'postgres';
2
+ import type { DrizzleDriver } from '../driver';
3
+ /**
4
+ * Options of {@link postgresJs}: a connection URL or options, or a client.
5
+ * @author Axel Nana <axel.nana@workbud.com>
6
+ */
7
+ export type PostgresJsDriverOptions = {
8
+ connection: string | ({
9
+ url?: string;
10
+ } & Options<Record<string, PostgresType>>);
11
+ } | {
12
+ client: Sql;
13
+ };
14
+ /**
15
+ * Connects with postgres.js (`postgres`), on Node.js, Bun and Cloudflare Workers.
16
+ * @author Axel Nana <axel.nana@workbud.com>
17
+ * @param options The connection URL or options, or a client.
18
+ * @example
19
+ * ```ts
20
+ * import { postgresJs } from '@elysiumjs/mnemosyne-drizzle/postgres-js';
21
+ *
22
+ * DrizzleModule.with({ connections: { default: { driver: postgresJs({ connection: url }) } } });
23
+ * ```
24
+ */
25
+ export declare const postgresJs: (options: PostgresJsDriverOptions) => DrizzleDriver;
@@ -0,0 +1 @@
1
+ import{drizzle}from"drizzle-orm/postgres-js";var postgresJs=(options)=>({name:"postgres-js",connect:(config)=>drizzle({...config,...options}),close:async(connection)=>{await connection.$client.end()}});export{postgresJs};
@@ -16,7 +16,7 @@ declare const DrizzleModule_base: (abstract new () => {
16
16
  readonly config: DrizzleModuleConfig;
17
17
  onInit(): Promise<void> | void;
18
18
  onDestroy(): Promise<void> | void;
19
- onServe(_app: import("elysia").default<any>): Promise<void> | void;
19
+ onServe(_app: import("elysia").Elysia<any>): Promise<void> | void;
20
20
  }) & {
21
21
  readonly config: string;
22
22
  with(config: Partial<DrizzleModuleConfig> | (() => Partial<DrizzleModuleConfig> | Promise<Partial<DrizzleModuleConfig>>)): import("@elysiumjs/core").ConfiguredModule<DrizzleModuleConfig>;
package/dist/index.d.ts CHANGED
@@ -3,8 +3,10 @@ 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
5
  export { Database, DEFAULT_CACHE_TTL, DrizzleDatabaseCache, TenantMismatchError } from './database';
6
- export type { DrizzleConnectionProps, DrizzleConnection } from './database';
6
+ export type { DrizzleConnectionProps, DrizzleConnection, DrizzleQueryResultHKT } from './database';
7
+ export { type DrizzleDriver, type DrizzleDriverConfig, MissingDriverError } from './driver';
7
8
  export { DrizzleModel, createSchemaFromDrizzle, drizzleAdapter, type DrizzleModelOptions } from './model';
8
9
  export { DrizzleModule, type DrizzleModuleConfig } from './drizzle.module';
10
+ export { compileAfter, compileFilter, compileQuery } from './query';
9
11
  export { DrizzleRepository } from './repository';
10
12
  export { DrizzleSchemaTenancy, DrizzleRLSTenancy, wrapTenantSchema, registerTenantSchema, getTenantSchema, withRLS, getSessionTenant, createRLSPolicy } from './tenancy';
package/dist/index.js CHANGED
@@ -1 +1,11 @@
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";
1
+ var __legacyDecorateClassTS=function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};var __legacyMetadataTS=(k,v)=>{if(typeof Reflect==="object"&&typeof Reflect.metadata==="function")return Reflect.metadata(k,v)};export*from"drizzle-orm";export*from"drizzle-orm/pg-core";import{pgTable as drizzlePgTable}from"drizzle-orm/pg-core";var tableRegistry=new WeakMap,getTableBuilders=(table)=>tableRegistry.get(table),pgTable=(tableName,columns,extraConfig)=>{let table=drizzlePgTable(tableName,columns,extraConfig);return tableRegistry.set(table,columns),table};import{ElysiumError as ElysiumError2}from"@elysiumjs/core";import{AbstractDatabase,getCurrentTenant as getCurrentTenant2,getTenancyConfig}from"@elysiumjs/mnemosyne";import{sql}from"drizzle-orm";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";var DEFAULT_CACHE_TTL=60,VERSION_TTL=86400000,MAX_PENDING_PER_KEY=16,MAX_PENDING_KEYS=1000,TABLE_SCHEMA=Symbol.for("drizzle:Schema"),tableKey=(table)=>{if(!is(table,Table))return table;let schema=table[TABLE_SCHEMA];return schema?`${schema}.${getTableName(table)}`:getTableName(table)},toArray=(value)=>value===void 0?[]:Array.isArray(value)?value:[value];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.token),this.#cache=createDatabaseCacheStorage({storage:this.#storageEngine})}return this.#cache}strategy(){return this.#cacheStrategy}async trackTransaction(run){let scope={tables:new Set,tags:new Set},result=await this.#transactionScope.run(scope,run);return await this.#invalidate([...scope.tables],[...scope.tags]),result}async get(key,tables=[],isTag=!1,autoInvalidate){if(this.#transactionScope.getStore())return;let storage=await this.#storage(),entryKey=this.#entryKey(key,isTag),versionKeys=autoInvalidate===!1?[]:tables.map((table)=>this.#versionKey(table)),[entry,...versions]=await storage.mget([entryKey,...versionKeys]),missing=versionKeys.flatMap((versionKey,index)=>versions[index]===void 0?[{index,versionKey}]:[]);if(missing.length>0){let created=missing.map(({versionKey})=>({key:versionKey,value:crypto.randomUUID(),ttl:VERSION_TTL}));await storage.mset(created);for(let[i,{index}]of missing.entries())versions[index]=created[i].value}let signature=versions.join("|"),cached=entry;if(cached!==void 0&&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;let entryKey=this.#entryKey(key,isTag),pending=this.#live(entryKey);if(pending.length===0||new Set(pending.map((miss)=>miss.signature)).size>1){this.#pending.delete(entryKey);return}let[miss,...rest]=pending;if(rest.length>0)this.#pending.set(entryKey,rest);else this.#pending.delete(entryKey);let storage=await this.#storage();if((await storage.mget(miss.versionKeys)).join("|")!==miss.signature)return;let signature=miss.signature,ttl=await this.#ttl(storage,entryKey,config);if(ttl<=0)return;await storage.set(entryKey,{v:signature,r:response},ttl)}async onMutate(params){let tables=toArray(params.tables).map(tableKey),tags=toArray(params.tags),scope=this.#transactionScope.getStore();if(scope){for(let table of tables)scope.tables.add(table);for(let tag of tags)scope.tags.add(tag);return}await this.#invalidate(tables,tags)}#remember(entryKey,miss){let 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){let 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;let 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,!0)))}async#ttl(storage,entryKey,config){if(config?.px!==void 0)return config.px;if(config?.ex!==void 0)return config.ex*1000;if(config?.pxat!==void 0)return config.pxat-Date.now();if(config?.exat!==void 0)return config.exat*1000-Date.now();if(config?.keepTtl){let expiresAt=await storage.ttl(entryKey);if(expiresAt!==void 0&&expiresAt>Date.now())return expiresAt-Date.now()}return this.#globalTtl*1000}#entryKey(key,isTag){let kind=isTag?"tag":"query";return`${this.#keyPrefix}:${kind}:${getCurrentTenant()??"public"}:${key}`}#versionKey(table){return`${this.#keyPrefix}:version:${table}`}}import{ElysiumError}from"@elysiumjs/core";class MissingDriverError extends ElysiumError{constructor(name){super(`Connection "${name}" sets no driver, and the default Bun SQL driver runs on Bun only. Set \`driver\` to postgresJs() or nodePostgres() on Node.js, or hyperdrive() on Cloudflare Workers.`)}}var connectionDrivers=new WeakMap,defaultDriver=(props,name)=>{if(typeof globalThis.Bun>"u")throw new MissingDriverError(name);let{drizzle}=import.meta.require("drizzle-orm/bun-sql"),client="client"in props?{client:props.client}:{connection:props.connection};return{name:"bun-sql",connect:(config)=>drizzle({...config,...client}),close:async(connection)=>{await connection.$client.close()}}},closeConnection=async(connection)=>{let driver=connectionDrivers.get(connection);if(driver)await driver.close(connection)};class TenantMismatchError extends ElysiumError2{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}}var transactionTenants=new WeakMap,bindTransactionTenant=async(tx,tenant)=>{let varName=getTenancyConfig().rls?.sessionVariable??"app.current_tenant";await tx.execute(sql`SELECT set_config(${varName}, ${tenant}, true)`),transactionTenants.set(tx,tenant)},assertTransactionTenant=(tx,tenant)=>{let bound=transactionTenants.get(tx);if(bound!==tenant)throw new TenantMismatchError(bound??null,tenant)};class DrizzleDatabase extends AbstractDatabase{getConnection(name="default"){let connection=super.getConnection(name);if(transactionTenants.has(connection)&&getTenancyConfig().mode==="rls")assertTransactionTenant(connection,getCurrentTenant2()??"public");return connection}async transaction(callback,name="default"){if(getTenancyConfig().mode==="rls"){let active=this.getActiveTransaction(this.getBaseConnection(name));if(active!==void 0)assertTransactionTenant(active,getCurrentTenant2()??"public")}return super.transaction(callback,name)}createConnection(props,name="default"){return this.driver.createConnection(props,name)}driver={createConnection(config,name="default"){let cache=config.cache===void 0?void 0:config.cache===!0?new DrizzleDatabaseCache(void 0,void 0,void 0,name):config.cache.enabled!==!1?new DrizzleDatabaseCache(config.cache.strategy,config.cache.ttl,config.cache.storage,name,config.cache.prefix):void 0,{driver:chosen,connection:_url,client:_client,cache:_cache,...drizzleConfig}=config,driver=chosen??defaultDriver(config,name),connection=driver.connect({...drizzleConfig,cache});if(connectionDrivers.set(connection,driver),cache===void 0)return connection;let transaction=(callback,txConfig)=>Object.getPrototypeOf(connection).transaction.call(connection,callback,txConfig);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,getCurrentTenant2()??"public");return callback(tx)})},getRawClient(connection){return connection.$client}}}var Database=new DrizzleDatabase;import{AbstractModel,createSchemaFromModel}from"@elysiumjs/mnemosyne";import{getTableConfig as getTableConfig2}from"drizzle-orm/pg-core";import{getTenancyConfig as getTenancyConfig2,isValidTenantId,withTenant}from"@elysiumjs/mnemosyne";import{sql as sql2}from"drizzle-orm";import{getTableConfig,pgPolicy,pgSchema,pgTable as pgTable2}from"drizzle-orm/pg-core";var tenantSchemas=new Map,schemaRegistry=new Map,assertTenantId=(tenant)=>{if(!isValidTenantId(tenant))throw Error(`Invalid tenant identifier: ${JSON.stringify(tenant.slice(0,64))}`)},getTenantSchema=(tenant)=>(assertTenantId(tenant),schemaRegistry.get(tenant)??registerTenantSchema(tenant)),registerTenantSchema=(tenant)=>{if(assertTenantId(tenant),schemaRegistry.has(tenant))return schemaRegistry.get(tenant);let tenantSchema=pgSchema(tenant);return schemaRegistry.set(tenant,tenantSchema),tenantSchema},wrapTenantSchema=(tenant,tableName,columns)=>{if(assertTenantId(tenant),tenant==="public")return pgTable2(tableName,columns);let fullTableName=`${tenant}.${tableName}`;if(tenantSchemas.has(fullTableName))return tenantSchemas.get(fullTableName);let schemaTable=getTenantSchema(tenant).table(tableName,columns);return tenantSchemas.set(fullTableName,schemaTable),schemaTable},withRLS=async(tenant,callback,connectionName="default")=>new DrizzleRLSTenancy().withIsolation(Database.getBaseConnection(connectionName),tenant??"public",()=>callback(Database.getConnection(connectionName))),getSessionTenant=async(connectionName="default")=>{let db=Database.getConnection(connectionName),varName=getTenancyConfig2().rls?.sessionVariable??"app.current_tenant",result=await db.execute(sql2`SELECT current_setting(${varName}, true) as tenant`),[row]=Array.isArray(result)?result:result.rows;return row?.tenant||null},createRLSPolicy=(tableName,columnName,policyName)=>{let config=getTenancyConfig2(),varName=config.rls?.sessionVariable??"app.current_tenant",column=columnName??config.rls?.defaultColumn??"tenant_id",name=policyName??`${tableName}_tenant_isolation`;return pgPolicy(name,{for:"all",to:"public",using:sql2`${sql2.identifier(column)} = current_setting(${varName}, true)::text`,withCheck:sql2`${sql2.identifier(column)} = current_setting(${varName}, true)::text`})};class DrizzleSchemaTenancy{mode="schema";resolveTable(table,tenant){let builders=getTableBuilders(table);if(!builders)throw Error("No column builders registered for table. Use pgTable() from '@elysiumjs/mnemosyne-drizzle' for tenant support.");let config=getTableConfig(table);return wrapTenantSchema(tenant,config.name,builders)}async withIsolation(_connection,_tenant,callback){return callback()}}class DrizzleRLSTenancy{mode="rls";resolveTable(table,_tenant){return table}async withIsolation(connection,tenant,callback){let active=Database.getActiveTransaction(connection);if(active!==void 0)return assertTransactionTenant(active,tenant),withTenant(tenant,callback);return connection.transaction(async(tx)=>(await bindTransactionTenant(tx,tenant),withTenant(tenant,()=>Database.runInTransaction(connection,tx,callback))))}}var mapDrizzleDataType=(col)=>{if(col.columnType==="PgUUID")return"uuid";if(col.dimensions)return"array";let[kind,constraint]=col.dataType.split(" ");switch(kind){case"number":return"number";case"boolean":return"boolean";case"array":return"array";case"bigint":return"bigint";case"object":switch(constraint){case"json":return"json";case"date":return"date";case"buffer":return"buffer";default:return"string"}default:return"string"}},drizzleAdapter={getTableName(table){return getTableConfig2(table).name},getColumns(table){let config=getTableConfig2(table);return config.columns.map((col)=>({name:col.name,dataType:mapDrizzleDataType(col),nullable:!col.notNull,hasDefault:col.hasDefault,isPrimaryKey:col.primary||config.primaryKeys.some((pk)=>pk.columns.some((c)=>c.name===col.name))}))},createTenantTable(table,tenant){let builders=getTableBuilders(table);if(!builders)throw Error("No column builders registered for table. Use pgTable() from '@elysiumjs/mnemosyne-drizzle' for tenant support.");let config=getTableConfig2(table);return wrapTenantSchema(tenant,config.name,builders)}},createSchemaFromDrizzle=(table,opts)=>createSchemaFromModel(drizzleAdapter.getColumns(table),opts),DrizzleModel=(table,options)=>{let config=getTableConfig2(table),builders=getTableBuilders(table),Base=AbstractModel(config.name,builders??{},drizzleAdapter,table);if(options?.supportTenancy!==void 0)Base.supportTenancy=options.supportTenancy;return Base};import{Module,needsDefaultAlias,runAll,Service as Service2}from"@elysiumjs/core";import{AbstractDatabase as AbstractDatabase2,DATABASE_TRANSACTION_SERVICE,MnemosyneModule,TENANCY_CONFIG_SERVICE,TENANCY_STRATEGY_SERVICE,withUnitOfWork}from"@elysiumjs/mnemosyne";import{access}from"node:fs/promises";import{Command,CommandArgumentType}from"@elysiumjs/core";var basename=(path)=>path.split(/[\\/]/).pop()??path,dirname=(path)=>path.replace(/[\\/][^\\/]*$/,"");var getMigrationName=(path)=>basename(path)==="migration.sql"?basename(dirname(path)):basename(path),descriptiveName=(name)=>name.replace(/\.sql$/,"").split("_").slice(1).join("_"),folderTime=(name)=>{let 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]):void 0},planMigrations=(files,applied)=>{let appliedNames=new Set(applied.map(({name})=>name)),fileNames=new Set(files.map(getMigrationName)),moved=new Map;for(let record of[...applied].sort((a,b)=>a.name.localeCompare(b.name))){if(!record.name.endsWith(".sql")||fileNames.has(record.name))continue;let key=descriptiveName(record.name);moved.set(key,[...moved.get(key)??[],record])}let plan={pending:[],renames:[]};for(let file of[...files].sort()){let name=getMigrationName(file);if(appliedNames.has(name))continue;let from=basename(file)==="migration.sql"?takeMoved(moved,name):void 0;if(from)plan.renames.push({from,to:name});else plan.pending.push(file)}return plan},takeMoved=(moved,name)=>{let records=moved.get(descriptiveName(name)),generatedAt=folderTime(name),index=records?.findIndex(({appliedAt})=>generatedAt===void 0||generatedAt<=appliedAt.getTime())??-1;return index<0?void 0:records.splice(index,1)[0].name},IDENTIFIER_CHAR=/[A-Za-z0-9_$]/;var 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},endOfVerbatim=(sql,i)=>{let previous=sql[i-1]??"";if(sql.startsWith("--",i)){let end=sql.indexOf(`
2
+ `,i);return end<0?sql.length:end}if(sql.startsWith("/*",i)){let end=sql.indexOf("*/",i+2);return end<0?sql.length:end+2}if(sql[i]==="'"){let escape=/[Ee]/.test(previous)&&!IDENTIFIER_CHAR.test(sql[i-2]??"");return endOfQuoted(sql,i,"'",escape)}if(sql[i]==="$"&&!IDENTIFIER_CHAR.test(previous)){let tag=/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i,i+64))?.[0];if(tag){let end=sql.indexOf(tag,i+tag.length);return end<0?sql.length:end+tag.length}}return-1},stripPublicSchema=(sql)=>{let output="",i=0;while(i<sql.length){let verbatim=endOfVerbatim(sql,i);if(verbatim>=0)output+=sql.slice(i,verbatim),i=verbatim;else if(sql.startsWith('"public".',i)&&sql[i+9]==='"')i+=9;else if(sql[i]==='"'){let end=endOfQuoted(sql,i,'"',!1);output+=sql.slice(i,end),i=end}else output+=sql[i],i++}return output};import{existsSync}from"node:fs";import{dirname as dirname2,join}from"node:path";var getProjectPath=()=>{let startPath=process.cwd(),dir=startPath;while(!0){if(existsSync(join(dir,".elysiumrc")))return dir;let parentDir=dirname2(dir);if(dir===parentDir)break;dir=parentDir}return startPath},getMigrationsPath=()=>join(getProjectPath(),"src/database/migrations");var exists=(path)=>access(path).then(()=>!0,()=>!1),listSqlFiles=async(migrationsPath)=>{if(!await exists(migrationsPath))return[];return Array.fromAsync(new Bun.Glob("**/*.sql").scan({cwd:migrationsPath,absolute:!0,onlyFiles:!0}))};class MigrationGenerateCommand extends Command{static command="migration:generate";static description="Generates migration files.";async run(){if(!this.name){let{default:prompts}=await import("prompts"),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!0}}],{onCancel:()=>{this.error("Operation cancelled."),process.exit(0)}});this.name=answers.name}let projectRoot=getProjectPath(),migrationsPath=getMigrationsPath(),schemaPath=`${projectRoot}/src/database/schemas`;if(await exists(`${projectRoot}/src/database/schema.ts`))schemaPath=`${projectRoot}/src/database/schema.ts`;let existing=new Set(await listSqlFiles(migrationsPath));await Bun.$`bunx --bun drizzle-kit generate --dialect postgresql --schema ${schemaPath} --out ${migrationsPath} --name ${this.name}`;for(let path of await listSqlFiles(migrationsPath)){if(existing.has(path))continue;let file=Bun.file(path);await file.write(stripPublicSchema(await file.text()))}}}__legacyDecorateClassTS([Command.arg({description:"The name of the migration to create",type:CommandArgumentType.STRING}),__legacyMetadataTS("design:type",String)],MigrationGenerateCommand.prototype,"name",void 0),MigrationGenerateCommand=__legacyDecorateClassTS([Command.register()],MigrationGenerateCommand);import{readdir}from"node:fs/promises";import{join as join2}from"node:path";import{Command as Command2,CommandArgumentType as CommandArgumentType2}from"@elysiumjs/core";import{isValidTenantId as isValidTenantId2}from"@elysiumjs/mnemosyne";var listMigrationFiles=async(folder)=>(await readdir(folder,{recursive:!0,withFileTypes:!0}).catch((error)=>{if(error.code==="ENOENT")return[];throw error})).filter((entry)=>entry.isFile()&&entry.name.endsWith(".sql")).map((entry)=>join2(entry.parentPath,entry.name));class MigrationRunCommand extends Command2{constructor(){super(...arguments);this.tenant="";this.connection="default";this.fresh=!1;this.createSchema=!1}static command="migration:run";static description="Runs pending migrations.";async run(){if(!isValidTenantId2(this.tenant))return 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.`),process.exit(1);if(!Database.connectionExists(this.connection))return this.error(`Connection ${this.bold(this.connection)} does not exist.`),process.exit(1);let 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)=>{if(await sql`SELECT pg_advisory_xact_lock(hashtext(${`elysium_migrations:${this.tenant}`}))`,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 (
3
+ id SERIAL PRIMARY KEY,
4
+ name TEXT NOT NULL,
5
+ batch INTEGER NOT NULL,
6
+ applied_at TIMESTAMP NOT NULL
7
+ );`,await sql`CREATE UNIQUE INDEX IF NOT EXISTS elysium_migrations_name_unique ON elysium_migrations (name);`;let completedMigrations=(await sql`SELECT name, applied_at FROM elysium_migrations;`).map((row)=>({name:row.name,appliedAt:row.applied_at})),files=await listMigrationFiles(getMigrationsPath()),{pending:sqlFiles,renames}=planMigrations(files,completedMigrations);for(let{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}let s=this.spinner(`Running migrations for tenant ${this.bold(this.tenant)}...`),[{max}]=await sql`SELECT MAX(batch) + 1 AS max FROM elysium_migrations;`,batch=max??0;for(let path of sqlFiles){await sql.file(path);let[{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){let[{schema_exists}]=await sql`SELECT EXISTS (
8
+ SELECT schema_name
9
+ FROM information_schema.schemata
10
+ WHERE schema_name = ${this.tenant}
11
+ ) AS schema_exists;`;if(schema_exists||this.createSchema)return!0;if(!process.stdin.isTTY)return this.error(`Schema ${this.bold(this.tenant)} does not exist. Run again with --create-schema to create it.`),!1;let{default:prompts}=await import("prompts");if(!(await prompts([{type:"confirm",name:"create",message:`Schema ${this.tenant} does not exist. Do you want to create it?`,initial:!0}],{onCancel:()=>{this.error("Operation cancelled."),process.exit(0)}})).create)return this.error(`Schema ${this.bold(this.tenant)} creation cancelled.`),!1;return!0}}__legacyDecorateClassTS([Command2.arg({description:"The name of the tenant in which the migrations will be executed.",type:CommandArgumentType2.STRING,required:!0}),__legacyMetadataTS("design:type",String)],MigrationRunCommand.prototype,"tenant",void 0),__legacyDecorateClassTS([Command2.arg({description:"The name of the connection to use.",type:CommandArgumentType2.STRING,default:"default"}),__legacyMetadataTS("design:type",String)],MigrationRunCommand.prototype,"connection",void 0),__legacyDecorateClassTS([Command2.arg({description:"Drop the entire schema before running the migrations.",type:CommandArgumentType2.BOOLEAN,default:!1}),__legacyMetadataTS("design:type",Boolean)],MigrationRunCommand.prototype,"fresh",void 0),__legacyDecorateClassTS([Command2.arg({name:"create-schema",description:"Create the tenant schema without asking when it does not exist.",type:CommandArgumentType2.BOOLEAN,default:!1}),__legacyMetadataTS("design:type",Boolean)],MigrationRunCommand.prototype,"createSchema",void 0),MigrationRunCommand=__legacyDecorateClassTS([Command2.register()],MigrationRunCommand);class DrizzleModule extends Module.configurable({defaults:{default:"default"},required:["connections"]}){async onDestroy(){let names=Object.keys(this.config.connections).map(AbstractDatabase2.getConnectionName);await runAll(names.filter((name)=>Service2.isConstructed(name)).map((name)=>()=>closeConnection(Service2.get(name))))}}DrizzleModule=__legacyDecorateClassTS([Module.register({imports:[MnemosyneModule],commands:[MigrationGenerateCommand,MigrationRunCommand],services:(config)=>{let alias=needsDefaultAlias("DrizzleModule",config),tenancy=config.tenancy??{mode:"schema"};return[...Object.entries(config.connections).map(([name,props])=>({name:AbstractDatabase2.getConnectionName(name),global:!0,factory:()=>Database.createConnection(props,name)})),...alias?[{name:AbstractDatabase2.getConnectionName("default"),global:!0,factory:(_config,get)=>get(AbstractDatabase2.getConnectionName(config.default))}]:[],{name:DATABASE_TRANSACTION_SERVICE,global:!0,factory:()=>withUnitOfWork((callback,connection)=>Database.transaction(()=>callback(),connection))},{name:TENANCY_CONFIG_SERVICE,global:!0,factory:()=>tenancy},{name:TENANCY_STRATEGY_SERVICE,global:!0,factory:()=>tenancy.mode==="rls"?new DrizzleRLSTenancy:new DrizzleSchemaTenancy}]}})],DrizzleModule);import{and,asc,desc,eq,gt,gte,ilike,inArray,isNotNull,isNull,lt,lte,ne,or}from"drizzle-orm";var escapeLike=(value)=>value.replace(/[\\%_]/g,(char)=>`\\${char}`),columnOf=(table,column)=>{let found=table[column];if(!found)throw Error(`The table has no column "${column}" for the query.`);return found},compileFilter=(table,filter)=>{let column=columnOf(table,filter.column),value=filter.value;switch(filter.operator){case"eq":return eq(column,value);case"ne":return ne(column,value);case"gt":return gt(column,value);case"gte":return gte(column,value);case"lt":return lt(column,value);case"lte":return lte(column,value);case"in":return inArray(column,value);case"contains":return ilike(column,`%${escapeLike(String(value))}%`);case"startsWith":return ilike(column,`${escapeLike(String(value))}%`);case"isNull":return value?isNull(column):isNotNull(column)}},compileAfter=(table,query)=>{if(!query.after)return;let branches=query.sort.map((key,index)=>{let equal=query.sort.slice(0,index).map((previous,i)=>eq(columnOf(table,previous.column),query.after[i])),column=columnOf(table,key.column),value=query.after[index];return and(...equal,key.direction==="asc"?gt(column,value):lt(column,value))});return or(...branches)},compileQuery=(table,query)=>({where:and(...query.filters.map((filter)=>compileFilter(table,filter)),compileAfter(table,query)),orderBy:query.sort.map(({column,direction})=>direction==="asc"?asc(columnOf(table,column)):desc(columnOf(table,column)))});import{AbstractRepository,getCurrentTenant as getCurrentTenant3,getTenancyConfig as getTenancyConfig3,getTenancyStrategy,Query}from"@elysiumjs/mnemosyne";import{eq as eq2}from"drizzle-orm";var DrizzleRepository=(model)=>{let table=()=>model.table;class R extends AbstractRepository(model,Database){async withConnection(operation){let name=this.constructor.connection;if(getTenancyConfig3().mode!=="rls")return operation(this.db);return(getTenancyStrategy()??new DrizzleRLSTenancy).withIsolation(Database.getBaseConnection(name),getCurrentTenant3()??"public",()=>operation(this.db))}async all(){return this.withConnection((db)=>db.select().from(table()))}async paginate(count,page=1){let 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){let[row]=await this.withConnection((db)=>db.select().from(table()).where(eq2(table().id,id)));return row??null}async findBy(column,value){let[row]=await this.withConnection((db)=>db.select().from(table()).where(eq2(table()[column],value)));return row??null}async insert(data){let[row]=await this.withConnection((db)=>db.insert(table()).values(data).returning());return row}async update(id,data){let[row]=await this.withConnection((db)=>db.update(table()).set(data).where(eq2(table().id,id)).returning());return row??null}async updateAll(data){return this.withConnection((db)=>db.update(table()).set(data).returning())}async delete(id){let[row]=await this.withConnection((db)=>db.delete(table()).where(eq2(table().id,id)).returning());return row??null}async deleteAll(){return this.withConnection((db)=>db.delete(table()).returning())}async list(query){let{where,orderBy}=compileQuery(table(),query),rows=await this.withConnection((db)=>db.select().from(table()).where(where).orderBy(...orderBy).limit(query.limit+1));return Query.page(query,rows)}async count(query){let where=query?compileQuery(table(),{...query,after:void 0}).where:void 0;return this.withConnection((db)=>db.$count(table(),where))}async exists(id){return await this.withConnection((db)=>db.$count(table(),eq2(table().id,id)))>0}}return R};export{DEFAULT_CACHE_TTL,Database,DrizzleDatabaseCache,DrizzleModel,DrizzleModule,DrizzleRLSTenancy,DrizzleRepository,DrizzleSchemaTenancy,MissingDriverError,TenantMismatchError,compileAfter,compileFilter,compileQuery,createRLSPolicy,createSchemaFromDrizzle,drizzleAdapter,getSessionTenant,getTableBuilders,getTenantSchema,pgTable,registerTenantSchema,withRLS,wrapTenantSchema};
package/dist/model.d.ts CHANGED
@@ -22,7 +22,7 @@ export declare const drizzleAdapter: ModelAdapter<PgTableWithColumns<any>>;
22
22
  */
23
23
  export declare const createSchemaFromDrizzle: (table: PgTableWithColumns<any>, opts?: {
24
24
  mode?: 'create' | 'update' | 'select';
25
- }) => import("@sinclair/typebox").TObject<{}>;
25
+ }) => import("typebox").TObject<{}>;
26
26
  /**
27
27
  * Options for creating a DrizzleModel.
28
28
  */
@@ -49,9 +49,9 @@ export declare const DrizzleModel: <TTable extends PgTable>(table: TTable, optio
49
49
  readonly table: any;
50
50
  readonly tableName: string;
51
51
  readonly columns: Record<string, import("drizzle-orm/pg-core").AnyPgColumnBuilder>;
52
- readonly insertSchema: import("@sinclair/typebox").TObject<{}>;
53
- readonly updateSchema: import("@sinclair/typebox").TObject<{}>;
54
- readonly selectSchema: import("@sinclair/typebox").TObject<{}>;
52
+ readonly insertSchema: import("typebox").TObject<{}>;
53
+ readonly updateSchema: import("typebox").TObject<{}>;
54
+ readonly selectSchema: import("typebox").TObject<{}>;
55
55
  readonly supportTenancy: boolean;
56
56
  } & {
57
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;
@@ -0,0 +1,28 @@
1
+ import type { ParsedFilter, ParsedQuery } from '@elysiumjs/mnemosyne';
2
+ import type { SQL } from 'drizzle-orm';
3
+ import type { PgTableWithColumns } from 'drizzle-orm/pg-core';
4
+ /**
5
+ * Compiles one filter of a parsed query to a Drizzle condition.
6
+ * @author Axel Nana <axel.nana@workbud.com>
7
+ * @param table The table queried.
8
+ * @param filter The filter.
9
+ */
10
+ export declare const compileFilter: (table: PgTableWithColumns<any>, filter: ParsedFilter) => SQL;
11
+ /**
12
+ * Compiles the condition that keeps the rows after the cursor, in sort order: a keyset condition
13
+ * over every sort key, the row key last.
14
+ * @author Axel Nana <axel.nana@workbud.com>
15
+ * @param table The table queried.
16
+ * @param query The parsed query, with `after` set.
17
+ */
18
+ export declare const compileAfter: (table: PgTableWithColumns<any>, query: ParsedQuery) => SQL | undefined;
19
+ /**
20
+ * Compiles a parsed query: the `where` condition (filters and cursor) and the `order by`.
21
+ * @author Axel Nana <axel.nana@workbud.com>
22
+ * @param table The table queried.
23
+ * @param query The parsed query.
24
+ */
25
+ export declare const compileQuery: (table: PgTableWithColumns<any>, query: ParsedQuery) => {
26
+ where: SQL<unknown> | undefined;
27
+ orderBy: SQL<unknown>[];
28
+ };
@@ -1,4 +1,4 @@
1
- import type { IdType, ModelClass } from '@elysiumjs/mnemosyne';
1
+ import type { CursorPage, IdType, ModelClass, ParsedQuery } from '@elysiumjs/mnemosyne';
2
2
  import type { PgColumn, PgTable } from 'drizzle-orm/pg-core';
3
3
  import type { DrizzleConnection } from './database';
4
4
  /**
@@ -42,6 +42,19 @@ export declare const DrizzleRepository: <TModel extends ModelClass<any, any, any
42
42
  updateAll(data: TModel["$inferUpdate"]): Promise<TModel["$inferSelect"][]>;
43
43
  delete(id: IdType): Promise<TModel["$inferSelect"] | null>;
44
44
  deleteAll(): Promise<TModel["$inferSelect"][]>;
45
+ /**
46
+ * Lists a page of rows matching a parsed query (see `Query.parse`): its filters, its sort and
47
+ * cursor pagination. One row more than the limit is fetched to tell whether a next page
48
+ * exists.
49
+ * @param query The parsed query.
50
+ * @returns The rows of the page and the cursor of the next one.
51
+ */
52
+ list(query: ParsedQuery): Promise<CursorPage<TModel["$inferSelect"]>>;
53
+ /**
54
+ * Counts the rows matching the filters of a parsed query, ignoring its cursor.
55
+ * @param query The parsed query.
56
+ */
57
+ count(query?: ParsedQuery): Promise<number>;
45
58
  exists(id: IdType): Promise<boolean>;
46
59
  readonly db: DrizzleConnection;
47
60
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elysiumjs/mnemosyne-drizzle",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Drizzle ORM driver for @elysiumjs/mnemosyne",
6
6
  "private": false,
@@ -17,12 +17,13 @@
17
17
  "scripts": {
18
18
  "format": "prettier --write \"./src/**/*.ts\"",
19
19
  "clean": "rimraf dist",
20
- "build": "bun run clean && tsc && bun run build:minify",
21
- "build:minify": "bun scripts/minify.ts",
20
+ "build": "bun run clean && bun run build:js && bun run build:ts",
22
21
  "typecheck": "tsc --noEmit && tsc -p tsconfig.types.json",
23
22
  "prepublishOnly": "bun run build",
24
23
  "test": "bun test",
25
- "test:coverage": "bun test --coverage"
24
+ "test:coverage": "bun test --coverage",
25
+ "build:js": "bun build ./src/index.ts ./src/drivers/bun-sql.ts ./src/drivers/postgres-js.ts ./src/drivers/node-postgres.ts ./src/drivers/hyperdrive.ts --outdir ./dist --target node --packages external --root ./src --minify-syntax --minify-whitespace",
26
+ "build:ts": "tsc --emitDeclarationOnly"
26
27
  },
27
28
  "keywords": [
28
29
  "elysium",
@@ -34,14 +35,39 @@
34
35
  "module": "dist/index.js",
35
36
  "type": "module",
36
37
  "types": "dist/index.d.ts",
38
+ "exports": {
39
+ ".": {
40
+ "types": "./dist/index.d.ts",
41
+ "import": "./dist/index.js"
42
+ },
43
+ "./bun-sql": {
44
+ "types": "./dist/drivers/bun-sql.d.ts",
45
+ "import": "./dist/drivers/bun-sql.js"
46
+ },
47
+ "./postgres-js": {
48
+ "types": "./dist/drivers/postgres-js.d.ts",
49
+ "import": "./dist/drivers/postgres-js.js"
50
+ },
51
+ "./node-postgres": {
52
+ "types": "./dist/drivers/node-postgres.d.ts",
53
+ "import": "./dist/drivers/node-postgres.js"
54
+ },
55
+ "./hyperdrive": {
56
+ "types": "./dist/drivers/hyperdrive.d.ts",
57
+ "import": "./dist/drivers/hyperdrive.js"
58
+ }
59
+ },
37
60
  "files": [
38
61
  "dist",
39
62
  "CHANGELOG.md"
40
63
  ],
41
64
  "sideEffects": false,
42
65
  "peerDependencies": {
43
- "@elysiumjs/core": "^0.10.0",
44
- "@elysiumjs/mnemosyne": "^0.3.1"
66
+ "@elysiumjs/core": "^0.11.1",
67
+ "@elysiumjs/mnemosyne": "^0.5.0",
68
+ "@elysiumjs/runtime-workers": "^0.1.0",
69
+ "pg": "^8.0.0",
70
+ "postgres": "^3.4.0"
45
71
  },
46
72
  "dependencies": {
47
73
  "drizzle-orm": "^1.0.0-rc.5",
@@ -49,8 +75,24 @@
49
75
  "radash": "^12.1.1"
50
76
  },
51
77
  "devDependencies": {
78
+ "@elysiumjs/core": "0.11.1",
79
+ "@elysiumjs/mnemosyne": "0.5.0",
80
+ "@elysiumjs/runtime-bun": "0.1.0",
81
+ "@types/pg": "8.15.5",
52
82
  "@types/prompts": "^2.4.9",
53
- "@elysiumjs/core": "0.10.0",
54
- "@elysiumjs/mnemosyne": "0.3.1"
83
+ "pg": "8.16.3",
84
+ "postgres": "3.4.7",
85
+ "@elysiumjs/runtime-workers": "0.1.0"
86
+ },
87
+ "peerDependenciesMeta": {
88
+ "@elysiumjs/runtime-workers": {
89
+ "optional": true
90
+ },
91
+ "pg": {
92
+ "optional": true
93
+ },
94
+ "postgres": {
95
+ "optional": true
96
+ }
55
97
  }
56
98
  }
package/dist/cache.js DELETED
@@ -1 +0,0 @@
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 +0,0 @@
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};
@@ -1,10 +0,0 @@
1
- var __decorate=function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};var __metadata=function(k,v){if(typeof Reflect==="object"&&typeof Reflect.metadata==="function")return Reflect.metadata(k,v)};import{Command,CommandArgumentType}from"@elysiumjs/core";import{isValidTenantId}from"@elysiumjs/mnemosyne";import prompts from"prompts";import{Database}from"../database";import{getMigrationName,planMigrations}from"../migrations";import{getMigrationsPath}from"./project";let MigrationRunCommand=class MigrationRunCommand extends Command{static command="migration:run";static description="Runs pending migrations.";tenant="";connection="default";fresh=false;createSchema=false;async run(){if(!isValidTenantId(this.tenant)){this.error(`Invalid tenant ${this.bold(this.tenant)}: use 1 to 63 letters, digits, underscores or `+"dashes, not starting with pg_ and other than information_schema.");return process.exit(1)}if(!Database.connectionExists(this.connection)){this.error(`Connection ${this.bold(this.connection)} does not exist.`);return process.exit(1)}const sql=Database.getConnection(this.connection).$client;await sql.connect();try{if(!this.fresh&&!await this.ensureSchemaChoice(sql))return process.exit(1);await this.migrate(sql)}finally{await sql.close()}}async migrate(sql){await sql.begin(async(sql)=>{await sql`SELECT pg_advisory_xact_lock(hashtext(${`elysium_migrations:${this.tenant}`}))`;if(this.fresh){await sql`DROP SCHEMA IF EXISTS ${sql(this.tenant)} CASCADE;`}await sql`CREATE SCHEMA IF NOT EXISTS ${sql(this.tenant)};`;await sql`SET LOCAL search_path TO ${sql(this.tenant)}`;await sql`CREATE TABLE IF NOT EXISTS elysium_migrations (
2
- id SERIAL PRIMARY KEY,
3
- name TEXT NOT NULL,
4
- batch INTEGER NOT NULL,
5
- applied_at TIMESTAMP NOT NULL
6
- );`;await sql`CREATE UNIQUE INDEX IF NOT EXISTS elysium_migrations_name_unique ON elysium_migrations (name);`;const rows=await sql`SELECT name, applied_at FROM elysium_migrations;`;const completedMigrations=rows.map((row)=>({name:row.name,appliedAt:row.applied_at}));const filesIt=new Bun.Glob("**/*.sql").scan({cwd:getMigrationsPath(),absolute:true,onlyFiles:true});const files=await Array.fromAsync(filesIt);const{pending:sqlFiles,renames}=planMigrations(files,completedMigrations);for(const{from,to}of renames){await sql`UPDATE elysium_migrations SET name = ${to} WHERE name = ${from};`;this.info(`Migration ${this.bold(from)} is now tracked as ${this.bold(to)}.`)}if(sqlFiles.length===0){this.success("No new migrations to apply.");return}const s=this.spinner(`Running migrations for tenant ${this.bold(this.tenant)}...`);const[{max}]=await sql`SELECT MAX(batch) + 1 AS max FROM elysium_migrations;`;const batch=max??0;for(const path of sqlFiles){await sql.file(path);const[{name}]=await sql`INSERT INTO elysium_migrations (name, batch, applied_at) VALUES (${getMigrationName(path)}, ${batch}, ${new Date}) RETURNING name;`;s.pause(`Migration ${this.bold(name)} applied successfully.`)}s.complete(`Migrations for tenant ${this.bold(this.tenant)} applied successfully.`)})}async ensureSchemaChoice(sql){const[{schema_exists}]=await sql`SELECT EXISTS (
7
- SELECT schema_name
8
- FROM information_schema.schemata
9
- WHERE schema_name = ${this.tenant}
10
- ) AS schema_exists;`;if(schema_exists||this.createSchema)return true;if(!process.stdin.isTTY){this.error(`Schema ${this.bold(this.tenant)} does not exist. Run again with --create-schema to `+"create it.");return false}const answers=await prompts([{type:"confirm",name:"create",message:`Schema ${this.tenant} does not exist. Do you want to create it?`,initial:true}],{onCancel:()=>{this.error("Operation cancelled.");process.exit(0)}});if(!answers.create){this.error(`Schema ${this.bold(this.tenant)} creation cancelled.`);return false}return true}};__decorate([Command.arg({description:"The name of the tenant in which the migrations will be executed.",type:CommandArgumentType.STRING,required:true}),__metadata("design:type",String)],MigrationRunCommand.prototype,"tenant",undefined);__decorate([Command.arg({description:"The name of the connection to use.",type:CommandArgumentType.STRING,default:"default"}),__metadata("design:type",String)],MigrationRunCommand.prototype,"connection",undefined);__decorate([Command.arg({description:"Drop the entire schema before running the migrations.",type:CommandArgumentType.BOOLEAN,default:false}),__metadata("design:type",Boolean)],MigrationRunCommand.prototype,"fresh",undefined);__decorate([Command.arg({name:"create-schema",description:"Create the tenant schema without asking when it does not exist.",type:CommandArgumentType.BOOLEAN,default:false}),__metadata("design:type",Boolean)],MigrationRunCommand.prototype,"createSchema",undefined);MigrationRunCommand=__decorate([Command.register()],MigrationRunCommand);export{MigrationRunCommand};
@@ -1 +0,0 @@
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.js DELETED
@@ -1 +0,0 @@
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;
@@ -1 +0,0 @@
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};
@@ -1,2 +0,0 @@
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.js DELETED
@@ -1 +0,0 @@
1
- import{AbstractModel,createSchemaFromModel}from"@elysiumjs/mnemosyne";import{getTableConfig}from"drizzle-orm/pg-core";import{getTableBuilders}from"./table";import{wrapTenantSchema}from"./tenancy";const mapDrizzleDataType=(col)=>{if(col.columnType==="PgUUID")return"uuid";if(col.dimensions)return"array";const[kind,constraint]=col.dataType.split(" ");switch(kind){case"number":return"number";case"boolean":return"boolean";case"array":return"array";case"bigint":return"bigint";case"object":switch(constraint){case"json":return"json";case"date":return"date";case"buffer":return"buffer";default:return"string"}default:return"string"}};export const drizzleAdapter={getTableName(table){return getTableConfig(table).name},getColumns(table){const config=getTableConfig(table);return config.columns.map((col)=>({name:col.name,dataType:mapDrizzleDataType(col),nullable:!col.notNull,hasDefault:col.hasDefault,isPrimaryKey:col.primary||config.primaryKeys.some((pk)=>pk.columns.some((c)=>c.name===col.name))}))},createTenantTable(table,tenant){const builders=getTableBuilders(table);if(!builders){throw new Error(`No column builders registered for table. `+`Use pgTable() from '@elysiumjs/mnemosyne-drizzle' for tenant support.`)}const config=getTableConfig(table);return wrapTenantSchema(tenant,config.name,builders)}};export const createSchemaFromDrizzle=(table,opts)=>{return createSchemaFromModel(drizzleAdapter.getColumns(table),opts)};export const DrizzleModel=(table,options)=>{const config=getTableConfig(table);const builders=getTableBuilders(table);const Base=AbstractModel(config.name,builders??{},drizzleAdapter,table);if(options?.supportTenancy!==undefined){Base.supportTenancy=options.supportTenancy}return Base};
@@ -1 +0,0 @@
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};
package/dist/table.js DELETED
@@ -1 +0,0 @@
1
- import{pgTable as drizzlePgTable}from"drizzle-orm/pg-core";const tableRegistry=new WeakMap;export const getTableBuilders=(table)=>{return tableRegistry.get(table)};export const pgTable=(tableName,columns,extraConfig)=>{const table=drizzlePgTable(tableName,columns,extraConfig);tableRegistry.set(table,columns);return table};
package/dist/tenancy.js DELETED
@@ -1 +0,0 @@
1
- import{getTenancyConfig,isValidTenantId,withTenant}from"@elysiumjs/mnemosyne";import{sql}from"drizzle-orm";import{getTableConfig,pgPolicy,pgSchema,pgTable}from"drizzle-orm/pg-core";import{assertTransactionTenant,bindTransactionTenant,Database}from"./database";import{getTableBuilders}from"./table";const tenantSchemas=new Map;const schemaRegistry=new Map;const assertTenantId=(tenant)=>{if(!isValidTenantId(tenant)){throw new Error(`Invalid tenant identifier: ${JSON.stringify(tenant.slice(0,64))}`)}};export const getTenantSchema=(tenant)=>{assertTenantId(tenant);return schemaRegistry.get(tenant)??registerTenantSchema(tenant)};export const registerTenantSchema=(tenant)=>{assertTenantId(tenant);if(schemaRegistry.has(tenant)){return schemaRegistry.get(tenant)}const tenantSchema=pgSchema(tenant);schemaRegistry.set(tenant,tenantSchema);return tenantSchema};export const wrapTenantSchema=(tenant,tableName,columns)=>{assertTenantId(tenant);if(tenant==="public"){return pgTable(tableName,columns)}const fullTableName=`${tenant}.${tableName}`;if(tenantSchemas.has(fullTableName)){return tenantSchemas.get(fullTableName)}const tenantSchema=getTenantSchema(tenant);const schemaTable=tenantSchema.table(tableName,columns);tenantSchemas.set(fullTableName,schemaTable);return schemaTable};export const withRLS=async(tenant,callback,connectionName="default")=>{return new DrizzleRLSTenancy().withIsolation(Database.getBaseConnection(connectionName),tenant??"public",()=>callback(Database.getConnection(connectionName)))};export const getSessionTenant=async(connectionName="default")=>{const db=Database.getConnection(connectionName);const config=getTenancyConfig();const varName=config.rls?.sessionVariable??"app.current_tenant";const[row]=await db.execute(sql`SELECT current_setting(${varName}, true) as tenant`);return row?.tenant||null};export const createRLSPolicy=(tableName,columnName,policyName)=>{const config=getTenancyConfig();const varName=config.rls?.sessionVariable??"app.current_tenant";const column=columnName??config.rls?.defaultColumn??"tenant_id";const name=policyName??`${tableName}_tenant_isolation`;return pgPolicy(name,{for:"all",to:"public",using:sql`${sql.identifier(column)} = current_setting(${varName}, true)::text`,withCheck:sql`${sql.identifier(column)} = current_setting(${varName}, true)::text`})};export class DrizzleSchemaTenancy{mode="schema";resolveTable(table,tenant){const builders=getTableBuilders(table);if(!builders){throw new Error(`No column builders registered for table. `+`Use pgTable() from '@elysiumjs/mnemosyne-drizzle' for tenant support.`)}const config=getTableConfig(table);return wrapTenantSchema(tenant,config.name,builders)}async withIsolation(_connection,_tenant,callback){return callback()}}export class DrizzleRLSTenancy{mode="rls";resolveTable(table,_tenant){return table}async withIsolation(connection,tenant,callback){const active=Database.getActiveTransaction(connection);if(active!==undefined){assertTransactionTenant(active,tenant);return withTenant(tenant,callback)}return connection.transaction(async(tx)=>{await bindTransactionTenant(tx,tenant);return withTenant(tenant,()=>Database.runInTransaction(connection,tx,callback))})}}