@decohere/data-typeorm 0.0.1-dev.125 → 0.0.1-dev.5

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 (75) hide show
  1. package/package.json +8 -24
  2. package/src/bootstrap-policy.ts +84 -0
  3. package/src/bootstrap.ts +76 -0
  4. package/src/data-source.factory.ts +51 -0
  5. package/src/database-options.ts +10 -0
  6. package/src/dialect-map.ts +12 -0
  7. package/{es6/src/inject-data-source.js → src/inject-data-source.ts} +15 -9
  8. package/src/scripts/user.ts +234 -0
  9. package/src/server-manager.ts +300 -0
  10. package/src/sql-pretty.transport.ts +29 -0
  11. package/src/type-orm.module.ts +155 -0
  12. package/src/type-orm.provider.ts +37 -0
  13. package/src/type-orm.repository.ts +33 -0
  14. package/src/typeorm-scribe.logger.ts +55 -0
  15. package/src/typeorm.extensions.ts +42 -0
  16. package/src/unit-of-work.ts +52 -0
  17. package/tests/bootstrap-schemas.docker.spec.ts +145 -0
  18. package/tests/bootstrap.spec.ts +156 -0
  19. package/tests/db-least-privilege.pentest.spec.ts +125 -0
  20. package/tests/postgres-container.ts +139 -0
  21. package/tests/prepare-database.docker.spec.ts +96 -0
  22. package/tests/server-manager.bootstrap.spec.ts +72 -0
  23. package/tests/trusted-dev-postgres.spec.ts +212 -0
  24. package/tests/type-orm.module.spec.ts +123 -0
  25. package/tests/typeorm.lifecycle.spec.ts +97 -0
  26. package/tests/unit-of-work.spec.ts +54 -0
  27. package/tests/user-scripts.pentest.spec.ts +42 -0
  28. package/LICENSE +0 -15
  29. package/commonjs/src/bootstrap-policy.js +0 -69
  30. package/commonjs/src/bootstrap.js +0 -142
  31. package/commonjs/src/data-source.factory.js +0 -42
  32. package/commonjs/src/database-options.js +0 -4
  33. package/commonjs/src/dialect-map.js +0 -26
  34. package/commonjs/src/index.js +0 -24
  35. package/commonjs/src/inject-data-source.js +0 -33
  36. package/commonjs/src/scripts/user.js +0 -259
  37. package/commonjs/src/server-manager.js +0 -294
  38. package/commonjs/src/sql-pretty.transport.js +0 -35
  39. package/commonjs/src/type-orm.module.js +0 -115
  40. package/commonjs/src/type-orm.provider.js +0 -36
  41. package/commonjs/src/type-orm.repository.js +0 -30
  42. package/commonjs/src/typeorm-scribe.logger.js +0 -70
  43. package/commonjs/src/typeorm.extensions.js +0 -60
  44. package/commonjs/src/unit-of-work.js +0 -49
  45. package/es6/src/bootstrap-policy.js +0 -55
  46. package/es6/src/bootstrap.js +0 -139
  47. package/es6/src/data-source.factory.js +0 -27
  48. package/es6/src/database-options.js +0 -1
  49. package/es6/src/dialect-map.js +0 -8
  50. package/es6/src/scripts/user.js +0 -209
  51. package/es6/src/server-manager.js +0 -284
  52. package/es6/src/sql-pretty.transport.js +0 -20
  53. package/es6/src/type-orm.module.js +0 -105
  54. package/es6/src/type-orm.provider.js +0 -26
  55. package/es6/src/type-orm.repository.js +0 -20
  56. package/es6/src/typeorm-scribe.logger.js +0 -60
  57. package/es6/src/typeorm.extensions.js +0 -50
  58. package/es6/src/unit-of-work.js +0 -39
  59. package/types/src/bootstrap-policy.d.ts +0 -19
  60. package/types/src/bootstrap.d.ts +0 -16
  61. package/types/src/data-source.factory.d.ts +0 -17
  62. package/types/src/database-options.d.ts +0 -9
  63. package/types/src/dialect-map.d.ts +0 -8
  64. package/types/src/index.d.ts +0 -7
  65. package/types/src/inject-data-source.d.ts +0 -14
  66. package/types/src/scripts/user.d.ts +0 -56
  67. package/types/src/server-manager.d.ts +0 -39
  68. package/types/src/sql-pretty.transport.d.ts +0 -2
  69. package/types/src/type-orm.module.d.ts +0 -17
  70. package/types/src/type-orm.provider.d.ts +0 -14
  71. package/types/src/type-orm.repository.d.ts +0 -12
  72. package/types/src/typeorm-scribe.logger.d.ts +0 -9
  73. package/types/src/typeorm.extensions.d.ts +0 -11
  74. package/types/src/unit-of-work.d.ts +0 -20
  75. /package/{es6/src/index.js → src/index.ts} +0 -0
@@ -0,0 +1,300 @@
1
+ import { type ConnectionInfo, DataConfigurationError } from "@decohere/data";
2
+ import { Pool } from "pg";
3
+ import {
4
+ alterDatabaseOwnerQuery,
5
+ createSchemaQuery,
6
+ createUserQuery,
7
+ databaseOwnerQuery,
8
+ defaultPrivilegesForAppQueries,
9
+ grantConnectQuery,
10
+ grantRoleMembershipQuery,
11
+ grantSchemaCreateQuery,
12
+ grantSchemaPrivilegesQuery,
13
+ grantSchemaUsageQuery,
14
+ hasDatabasePrivilegeQuery,
15
+ hasRolePrivilegesQuery,
16
+ hasSchemaPrivilegeQuery,
17
+ quoteIdent,
18
+ revokeRoleMembershipQuery,
19
+ roleExistsQuery,
20
+ type SqlQuery,
21
+ } from "./scripts/user.js";
22
+
23
+ export type ProvisionRolesOptions = {
24
+ database: string;
25
+ appUsername: string;
26
+ /** Omit only when the server is declared `auth: trust`. */
27
+ appPassword?: string;
28
+ migrateUsername?: string;
29
+ migratePassword?: string;
30
+ /** When true (TypeORM synchronize), grant CREATE on the schema to the app role (dev only). */
31
+ allowAppDdl: boolean;
32
+ /** The schema the app role works in, created when missing. Default `public`. */
33
+ schema?: string;
34
+ };
35
+
36
+ /**
37
+ * Official Postgres images always have this DB. `pg` defaults an omitted
38
+ * `database` to the username — bootstrap roles like `decohere_bootstrap` are
39
+ * not databases, so connect here before CREATE DATABASE.
40
+ */
41
+ const MAINTENANCE_DATABASE = "postgres";
42
+
43
+ export class PostgresServerManager {
44
+ private pool: Pool;
45
+ private client: import("pg").PoolClient;
46
+
47
+ public constructor(
48
+ private readonly applicationName: string,
49
+ private readonly config: ConnectionInfo,
50
+ ) {}
51
+
52
+ /** `auth: trust`: the server lets every connection in, so roles have no password. */
53
+ private get trusted(): boolean {
54
+ return this.config.auth === "trust";
55
+ }
56
+
57
+ public async connect() {
58
+ const { adminUser, adminPassword, host, port } = this.config;
59
+ if (!adminUser || (!adminPassword && !this.trusted)) {
60
+ throw new DataConfigurationError(
61
+ `Data source "${this.config.name}": adminUser and adminPassword are required to bootstrap the database (adminPassword may be left out only with auth: trust)`,
62
+ );
63
+ }
64
+ this.pool = new Pool({
65
+ user: adminUser,
66
+ host,
67
+ password: adminPassword,
68
+ port,
69
+ database: MAINTENANCE_DATABASE,
70
+ application_name: this.applicationName,
71
+ });
72
+ try {
73
+ this.client = await this.pool.connect();
74
+ } catch (err) {
75
+ const cause = err instanceof Error ? err.message : String(err);
76
+ throw new DataConfigurationError(
77
+ `Cannot connect to PostgreSQL at ${host}:${port}. Is the server running? Cause: ${cause}`,
78
+ );
79
+ }
80
+ }
81
+
82
+ public async initializeDatabase(database: string) {
83
+ quoteIdent(database, "database");
84
+ const result = await this.pool.query(
85
+ `SELECT datname FROM pg_catalog.pg_database WHERE lower(datname) = lower($1)`,
86
+ [database],
87
+ );
88
+ if (result.rowCount === 1) {
89
+ return;
90
+ }
91
+
92
+ const dbIdent = quoteIdent(database, "database");
93
+ await this.pool.query(`CREATE DATABASE ${dbIdent}`);
94
+ }
95
+
96
+ /**
97
+ * Create the runtime app role (+ optional migrate role) and its schema, with least-privilege
98
+ * grants. Running it again changes nothing: each grant is made only where the role lacks it,
99
+ * since Postgres checks the grantor's rights even for a grant that would change nothing.
100
+ *
101
+ * The admin acts with the database owner's rights while it runs (it is made a member of the
102
+ * owner, and of the migrate role, only for as long as it needs to be). That is how it creates a
103
+ * schema and grants on it once the migrate role owns the database.
104
+ */
105
+ public async provisionRoles(options: ProvisionRolesOptions): Promise<void> {
106
+ const {
107
+ database,
108
+ appUsername,
109
+ appPassword,
110
+ migrateUsername,
111
+ migratePassword,
112
+ allowAppDdl,
113
+ schema = "public",
114
+ } = options;
115
+ quoteIdent(schema, "schema");
116
+
117
+ if (appUsername === this.config.adminUser) {
118
+ throw new DataConfigurationError(
119
+ `Runtime username "${appUsername}" must not equal adminUser — use a dedicated app role`,
120
+ );
121
+ }
122
+ if (migrateUsername && migrateUsername === appUsername) {
123
+ throw new DataConfigurationError(
124
+ "migrateUsername must differ from the runtime app username",
125
+ );
126
+ }
127
+ if (migrateUsername && migrateUsername === this.config.adminUser) {
128
+ throw new DataConfigurationError("migrateUsername must not equal adminUser");
129
+ }
130
+
131
+ const adminUser = this.config.adminUser;
132
+ if (!adminUser) {
133
+ throw new DataConfigurationError("adminUser is required to provision database roles");
134
+ }
135
+
136
+ const pool = new Pool({
137
+ host: this.config.host,
138
+ port: this.config.port,
139
+ user: adminUser,
140
+ database,
141
+ password: this.config.adminPassword,
142
+ application_name: this.applicationName,
143
+ });
144
+
145
+ try {
146
+ await using roles = new BorrowedRoles(pool, adminUser);
147
+
148
+ if (migrateUsername) {
149
+ await this.ensureRole(pool, migrateUsername, migratePassword);
150
+ // Membership is required for OWNER TO and DEFAULT PRIVILEGES FOR ROLE (not superuser).
151
+ await roles.borrow(migrateUsername);
152
+ // The owner holds every privilege on the database, so no separate GRANT is needed.
153
+ if ((await databaseOwner(pool, database)) !== migrateUsername) {
154
+ await pool.query(alterDatabaseOwnerQuery(database, migrateUsername).text);
155
+ }
156
+ }
157
+
158
+ const owner = await databaseOwner(pool, database);
159
+ if (owner !== adminUser) await roles.borrow(owner);
160
+
161
+ // Owned by the database owner (the migrate role, when there is one), like `public`.
162
+ await pool.query(createSchemaQuery(schema, owner).text);
163
+
164
+ if (
165
+ migrateUsername &&
166
+ !(await hasAll(pool, (privilege) =>
167
+ hasSchemaPrivilegeQuery(migrateUsername, schema, privilege),
168
+ ))
169
+ ) {
170
+ await pool.query(grantSchemaPrivilegesQuery(migrateUsername, schema).text);
171
+ }
172
+
173
+ await this.ensureRole(pool, appUsername, appPassword);
174
+ if (
175
+ !(await granted(pool, hasDatabasePrivilegeQuery(appUsername, database, "CONNECT")))
176
+ ) {
177
+ await pool.query(grantConnectQuery(appUsername, database).text);
178
+ }
179
+ if (!(await granted(pool, hasSchemaPrivilegeQuery(appUsername, schema, "USAGE")))) {
180
+ await pool.query(grantSchemaUsageQuery(appUsername, schema).text);
181
+ }
182
+ // Dev synchronize: the app connection must be able to CREATE tables.
183
+ if (
184
+ allowAppDdl &&
185
+ !(await granted(pool, hasSchemaPrivilegeQuery(appUsername, schema, "CREATE")))
186
+ ) {
187
+ await pool.query(grantSchemaCreateQuery(appUsername, schema).text);
188
+ }
189
+
190
+ if (migrateUsername) {
191
+ // Setting a default privilege that is already set changes nothing.
192
+ for (const q of defaultPrivilegesForAppQueries(migrateUsername, appUsername, schema)) {
193
+ await pool.query(q.text);
194
+ }
195
+ }
196
+ } finally {
197
+ await pool.end();
198
+ }
199
+ }
200
+
201
+ /** @deprecated Use provisionRoles. */
202
+ public async createApiUser(
203
+ username: string,
204
+ password: string,
205
+ database: string,
206
+ _role?: string,
207
+ ) {
208
+ await this.provisionRoles({
209
+ database,
210
+ appUsername: username,
211
+ appPassword: password,
212
+ migrateUsername: this.config.migrateUsername,
213
+ migratePassword: this.config.migratePassword,
214
+ allowAppDdl: this.config.synchronize === true,
215
+ schema: this.config.schema,
216
+ });
217
+ }
218
+
219
+ private async ensureRole(pool: Pool, username: string, password?: string): Promise<void> {
220
+ if (!password && !this.trusted) {
221
+ throw new DataConfigurationError(
222
+ `A password is required for database role "${username}" unless the data source sets auth: trust`,
223
+ );
224
+ }
225
+ const exists = roleExistsQuery(username);
226
+ const found = await pool.query(exists.text, exists.values);
227
+ if (found.rowCount === 1) {
228
+ return;
229
+ }
230
+ const create = createUserQuery(username, password);
231
+ await pool.query(create.text, create.values);
232
+ }
233
+
234
+ public [Symbol.asyncDispose]() {
235
+ return this.closeConnection();
236
+ }
237
+
238
+ public async closeConnection() {
239
+ this.client?.release();
240
+ await this.pool?.end();
241
+ }
242
+ }
243
+
244
+ async function granted(pool: Pool, query: SqlQuery): Promise<boolean> {
245
+ const result = await pool.query(query.text, query.values);
246
+ return result.rows[0]?.granted === true;
247
+ }
248
+
249
+ /** Every schema privilege that `GRANT ALL ON SCHEMA` gives. Checked one at a time: Postgres
250
+ * answers true for a list when any one of them is held. */
251
+ async function hasAll(pool: Pool, query: (privilege: string) => SqlQuery): Promise<boolean> {
252
+ for (const privilege of ["USAGE", "CREATE"]) {
253
+ if (!(await granted(pool, query(privilege)))) return false;
254
+ }
255
+ return true;
256
+ }
257
+
258
+ async function databaseOwner(pool: Pool, database: string): Promise<string> {
259
+ const query = databaseOwnerQuery(database);
260
+ const result = await pool.query(query.text, query.values);
261
+ const owner: string | undefined = result.rows[0]?.owner;
262
+ if (owner == null) {
263
+ throw new DataConfigurationError(`Database "${database}" does not exist`);
264
+ }
265
+ return owner;
266
+ }
267
+
268
+ /**
269
+ * Roles the admin is made a member of for one provisioning run, so it acts with their rights.
270
+ * Disposing gives back exactly what was borrowed, even when the run fails: memberships the admin
271
+ * already had (or rights it has as a superuser) are left alone.
272
+ */
273
+ class BorrowedRoles {
274
+ private readonly borrowed: string[] = [];
275
+
276
+ public constructor(
277
+ private readonly pool: Pool,
278
+ private readonly adminUser: string,
279
+ ) {}
280
+
281
+ public async borrow(role: string): Promise<void> {
282
+ if (this.borrowed.includes(role)) return;
283
+ if (await granted(this.pool, hasRolePrivilegesQuery(this.adminUser, role))) return;
284
+ try {
285
+ await this.pool.query(grantRoleMembershipQuery(this.adminUser, role).text);
286
+ } catch (err) {
287
+ throw new DataConfigurationError(
288
+ `Bootstrap role "${this.adminUser}" cannot act as "${role}". Make it a member of "${role}" (or create "${role}" with it), then run again.`,
289
+ { cause: err },
290
+ );
291
+ }
292
+ this.borrowed.push(role);
293
+ }
294
+
295
+ public async [Symbol.asyncDispose](): Promise<void> {
296
+ for (const role of this.borrowed.reverse()) {
297
+ await this.pool.query(revokeRoleMembershipQuery(this.adminUser, role).text);
298
+ }
299
+ }
300
+ }
@@ -0,0 +1,29 @@
1
+ import type { LogQuery } from "@decohere/data";
2
+ import pinoPretty from "pino-pretty";
3
+ import { PlatformTools } from "typeorm/platform/PlatformTools.js";
4
+
5
+ export const SqlPretty = (opts?) =>
6
+ pinoPretty({
7
+ colorizeObjects: true,
8
+ customLevels:
9
+ Object.entries(opts?.levels ?? [])
10
+ .map(([key, value]) => `${key}:${value}`)
11
+ .join(",") ?? [],
12
+
13
+ customPrettifiers: {
14
+ sql: tryParseSql,
15
+ },
16
+
17
+ ...opts,
18
+ });
19
+
20
+ function tryParseSql(value: string | LogQuery) {
21
+ const isString = typeof value === "string";
22
+ const statement = isString ? value : value.statement;
23
+ try {
24
+ const formatted = PlatformTools.highlightSql(PlatformTools.formatSql(statement));
25
+ return formatted;
26
+ } catch {
27
+ return statement;
28
+ }
29
+ }
@@ -0,0 +1,155 @@
1
+ import {
2
+ type ConnectionInfo,
3
+ type DatabaseConfig,
4
+ DataConfigurationError,
5
+ DEFAULT_DATA_SOURCE,
6
+ type Dialect,
7
+ type Provider,
8
+ } from "@decohere/data";
9
+ import { type IReadinessCheck, ReadinessCheckToken } from "@decohere/http";
10
+ import type { ContainerBuilder } from "@decohere/di";
11
+ import { memoize } from "@decohere/core/caching";
12
+ import type { DataSource, Logger } from "typeorm";
13
+ import { bootstrapDataSources } from "./bootstrap.js";
14
+ import { DataSourceFactory } from "./data-source.factory.js";
15
+ import type { ModuleConfig } from "./database-options.js";
16
+ import { typeOrmDataSourceToken } from "./inject-data-source.js";
17
+ import { TypeOrmScribeLogger } from "./typeorm-scribe.logger.js";
18
+ import { TypeOrmUnitOfWork } from "./unit-of-work.js";
19
+
20
+ export type TypeOrmModuleOptions = {
21
+ applicationName: string;
22
+ databaseConfig: DatabaseConfig;
23
+ moduleConfigurations: ModuleConfig[];
24
+ containerBuilder: ContainerBuilder;
25
+ };
26
+
27
+ export class TypeOrmModule {
28
+ /** Registers and opens a DataSource per module. Returns the names it registered them under. */
29
+ public async configure(options: TypeOrmModuleOptions): Promise<(string | symbol)[]> {
30
+ const { moduleConfigurations, databaseConfig, containerBuilder, applicationName } =
31
+ options;
32
+
33
+ if (databaseConfig.dataSources.length === 0) {
34
+ throw new DataConfigurationError("No datasources detected in config");
35
+ }
36
+
37
+ const modules = this.resolveDataSources(databaseConfig, moduleConfigurations);
38
+ // Once, before any DataSource opens: modules sharing a database must not race to create it.
39
+ await bootstrapDataSources(
40
+ modules.map((m) => m.connectionInfo),
41
+ { applicationName },
42
+ );
43
+
44
+ return await Promise.all(
45
+ modules.map(async ({ name, connectionInfo, moduleConfig }) => {
46
+ await this.init(applicationName, name, connectionInfo, moduleConfig, containerBuilder);
47
+ return name;
48
+ }),
49
+ );
50
+ }
51
+
52
+ private resolveDataSources(
53
+ databaseConfig: DatabaseConfig,
54
+ moduleConfigurations: ModuleConfig[],
55
+ ): { name: string | symbol; connectionInfo: ConnectionInfo; moduleConfig: ModuleConfig }[] {
56
+ if (databaseConfig.dataSources.length === 1 && moduleConfigurations.length === 1) {
57
+ const moduleConfig = moduleConfigurations.at(0);
58
+ return [
59
+ {
60
+ name: moduleConfig.name ?? DEFAULT_DATA_SOURCE,
61
+ connectionInfo: databaseConfig.dataSources.at(0),
62
+ moduleConfig,
63
+ },
64
+ ];
65
+ }
66
+
67
+ return moduleConfigurations.map((moduleConfig) => {
68
+ const { name } = moduleConfig;
69
+ if (name == null) {
70
+ throw new DataConfigurationError(
71
+ `Name is required if there is more than one datasource`,
72
+ );
73
+ }
74
+
75
+ const connectionInfo = databaseConfig.dataSources.find((ds) => ds.name === name);
76
+ if (connectionInfo == null) {
77
+ throw new DataConfigurationError(`Cannot find connection info for ${name.toString()}`);
78
+ }
79
+
80
+ return { name, connectionInfo, moduleConfig };
81
+ });
82
+ }
83
+
84
+ private async init(
85
+ applicationName: string,
86
+ name: string | symbol,
87
+ connectionInfo: ConnectionInfo,
88
+ moduleConfig: ModuleConfig,
89
+ builder: ContainerBuilder,
90
+ ) {
91
+ const logger = this.getLoggerFor(name, connectionInfo.dialect);
92
+ const dataSource = await this.configureTypeOrmDataSource(
93
+ applicationName,
94
+ name,
95
+ moduleConfig,
96
+ connectionInfo,
97
+ builder,
98
+ logger,
99
+ );
100
+ if (connectionInfo.seed) {
101
+ await moduleConfig.seed?.(dataSource);
102
+ }
103
+ }
104
+
105
+ private async configureTypeOrmDataSource(
106
+ applicationName: string,
107
+ name: string | symbol,
108
+ moduleConfig: ModuleConfig,
109
+ connectionInfo: ConnectionInfo,
110
+ builder: ContainerBuilder,
111
+ logger: Logger,
112
+ ) {
113
+ const { entities, repositories } = moduleConfig;
114
+ const dataSource = DataSourceFactory.create(
115
+ {
116
+ entities,
117
+ applicationName,
118
+ ...connectionInfo,
119
+ },
120
+ logger,
121
+ );
122
+
123
+ await dataSource.initialize();
124
+ const unitOfWork = () => new TypeOrmUnitOfWork(dataSource);
125
+ builder.registerRequestScoped(name, unitOfWork);
126
+ builder.registerSingleton(typeOrmDataSourceToken(name), dataSource);
127
+ builder.registerSingleton(
128
+ ReadinessCheckToken,
129
+ createTypeOrmReadinessCheck(name, dataSource),
130
+ );
131
+
132
+ for (const repo of repositories ?? []) {
133
+ builder.registerRequestScoped(repo);
134
+ }
135
+
136
+ return dataSource;
137
+ }
138
+
139
+ @memoize()
140
+ private getLoggerFor(name: string | symbol, dialectOrProvider: Dialect | Provider) {
141
+ return new TypeOrmScribeLogger(name.toString(), dialectOrProvider);
142
+ }
143
+ }
144
+
145
+ function createTypeOrmReadinessCheck(
146
+ name: string | symbol,
147
+ dataSource: DataSource,
148
+ ): IReadinessCheck {
149
+ return {
150
+ name: `typeorm:${String(name)}`,
151
+ async check() {
152
+ await dataSource.query("SELECT 1");
153
+ },
154
+ };
155
+ }
@@ -0,0 +1,37 @@
1
+ import type { Entity, IDataProvider } from "@decohere/data";
2
+ import type { Constructable } from "@decohere/core/reflection";
3
+ import type { FindManyOptions, FindOneOptions } from "typeorm";
4
+ import type { TypeOrmUnitOfWork } from "./unit-of-work.js";
5
+
6
+ export class TypeOrmQueryProvider implements IDataProvider {
7
+ public constructor(private unitOfWork: TypeOrmUnitOfWork) {}
8
+
9
+ public firstOrDefault<T extends Entity>(
10
+ entity: Constructable<T>,
11
+ options: FindOneOptions<T>,
12
+ ) {
13
+ return this.unitOfWork.entityManager.findOne(entity, options);
14
+ }
15
+
16
+ public all<T>(entity: Constructable, options?: FindManyOptions<T>): Promise<T[]> {
17
+ return this.unitOfWork.getRepository<T>(entity).find(options);
18
+ }
19
+
20
+ public findById<T extends Entity>(entity: Constructable<T>, key: number | string) {
21
+ return this.unitOfWork.entityManager.findOneBy<Entity>(entity, {
22
+ id: key,
23
+ }) as Promise<T>;
24
+ }
25
+
26
+ public save<T>(entity: T): Promise<T> {
27
+ return this.unitOfWork.entityManager.save(entity);
28
+ }
29
+
30
+ public query<T extends Entity>(entity: Constructable<T>, filter: FindManyOptions<T>) {
31
+ return this.unitOfWork.entityManager.find(entity, filter);
32
+ }
33
+
34
+ public delete<T>(entity: T) {
35
+ return this.unitOfWork.entityManager.remove(entity);
36
+ }
37
+ }
@@ -0,0 +1,33 @@
1
+ import { type Entity, Repository } from "@decohere/data";
2
+ import type { Constructable } from "@decohere/core/reflection";
3
+ import type { FindOneOptions } from "typeorm";
4
+ import { TypeOrmQueryProvider } from "./type-orm.provider.js";
5
+ import type { TypeOrmUnitOfWork } from "./unit-of-work.js";
6
+
7
+ export abstract class TypeOrmRepository<T extends Entity> extends Repository<T> {
8
+ protected provider: TypeOrmQueryProvider;
9
+ protected type: Constructable<T>;
10
+ public constructor(unitOfWork: TypeOrmUnitOfWork, type: Constructable<T>) {
11
+ super();
12
+ this.type = type;
13
+ this.provider = new TypeOrmQueryProvider(unitOfWork);
14
+ }
15
+
16
+ public firstOrDefault(options: FindOneOptions<T>): Promise<T | null>;
17
+ public firstOrDefault<U extends Entity>(
18
+ type: Constructable<U>,
19
+ options: FindOneOptions<U>,
20
+ ): Promise<U | null>;
21
+ public firstOrDefault<U extends Entity>(
22
+ optionsOrType: FindOneOptions<T> | Constructable<U>,
23
+ options?: FindOneOptions<U>,
24
+ ): Promise<T | U | null> {
25
+ if (typeof optionsOrType === "function") {
26
+ if (!options) {
27
+ throw new Error("options parameter is required when type is provided");
28
+ }
29
+ return this.provider.firstOrDefault(optionsOrType, options);
30
+ }
31
+ return this.provider.firstOrDefault(this.type, optionsOrType);
32
+ }
33
+ }
@@ -0,0 +1,55 @@
1
+ import type { Dialect, Provider } from "@decohere/data";
2
+ import { cache } from "@decohere/core/caching";
3
+ import { type DefaultLevels, type Logger, scribe } from "@decohere/scribe";
4
+ import {
5
+ AbstractLogger,
6
+ type LogLevel,
7
+ type LogMessage,
8
+ type LogMessageType,
9
+ type QueryRunner,
10
+ } from "typeorm";
11
+
12
+ const MessageTypeMap = {
13
+ log: "info",
14
+ info: "info",
15
+ query: "info",
16
+ warn: "warn",
17
+ error: "error",
18
+ "query-error": "error",
19
+ "query-slow": "warn",
20
+ "schema-build": "info",
21
+ migration: "info",
22
+ } satisfies Record<LogMessageType, DefaultLevels>;
23
+
24
+ export class TypeOrmScribeLogger extends AbstractLogger {
25
+ private readonly logger: Logger;
26
+ private readonly dialect: Dialect | Provider;
27
+ public constructor(name: string, dialect: Dialect | Provider, enable: boolean = true) {
28
+ super(enable);
29
+ this.dialect = dialect;
30
+ this.logger = scribe.logger(`${name ?? "TypeOrm"}`);
31
+ }
32
+
33
+ protected writeLog(
34
+ level: LogLevel,
35
+ logMessage: LogMessage | LogMessage[],
36
+ _queryRunner?: QueryRunner,
37
+ ) {
38
+ const messages = Array.isArray(logMessage) ? logMessage : [logMessage];
39
+ for (const message of messages) {
40
+ const logLevel = this.getLogLevel(message.type);
41
+ const prefix = message.type ?? level;
42
+ const statement = { sql: { statement: message.message, language: this.dialect } };
43
+ this.logger[logLevel](prefix, statement);
44
+ }
45
+ }
46
+
47
+ @cache()
48
+ private getLogLevel(type: LogMessageType | LogLevel) {
49
+ const level = Object.entries(MessageTypeMap)
50
+ .find(([key]) => type === key)
51
+ ?.at(1);
52
+
53
+ return this.logger.isLevelEnabled(level) ? level : this.logger.currentLevel;
54
+ }
55
+ }
@@ -0,0 +1,42 @@
1
+ import { DatabaseConfig } from "@decohere/data";
2
+ import { Builder } from "@decohere/http";
3
+ import { extension } from "@decohere/core/extensibility";
4
+ import type { Logger } from "typeorm";
5
+ import type { ModuleConfig } from "./database-options.js";
6
+ import { typeOrmDataSourceToken } from "./inject-data-source.js";
7
+ import { TypeOrmModule } from "./type-orm.module.js";
8
+
9
+ declare module "@decohere/http" {
10
+ interface Builder {
11
+ withTypeOrm(options?: ModuleConfig[] | ModuleConfig, logger?: Logger): Builder;
12
+ }
13
+ }
14
+
15
+ export class BuilderExtensions {
16
+ @extension(Builder)
17
+ public static withTypeOrm(builder: Builder, config: ModuleConfig[] | ModuleConfig) {
18
+ const names: (string | symbol)[] = [];
19
+ builder.registerPreBuildStep(async (configService, containerBuilder) => {
20
+ const databaseConfig = await configService.get(DatabaseConfig);
21
+ const module = new TypeOrmModule();
22
+ const moduleConfigurations = Array.isArray(config) ? config : [config];
23
+ const applicationName = builder.applicationName;
24
+ names.push(
25
+ ...(await module.configure({
26
+ applicationName,
27
+ databaseConfig,
28
+ moduleConfigurations,
29
+ containerBuilder,
30
+ })),
31
+ );
32
+ });
33
+ // The container disposes only what it has handed out, and nothing may ever ask for a
34
+ // DataSource by its token. Resolving each here makes the root container own it, as one of
35
+ // its first instances, so it is closed last: after hosted services and other services
36
+ // that use it (#541).
37
+ builder.registerPostBuildStep((container) => {
38
+ for (const name of names) container.resolve(typeOrmDataSourceToken(name));
39
+ });
40
+ return builder;
41
+ }
42
+ }