@xemahq/biome-database-sdk 0.1.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/LICENSE +201 -0
- package/README.md +65 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/migration-discovery.d.ts +3 -0
- package/dist/lib/migration-discovery.d.ts.map +1 -0
- package/dist/lib/migration-discovery.js +93 -0
- package/dist/lib/migration-discovery.js.map +1 -0
- package/dist/lib/migration-runner.d.ts +3 -0
- package/dist/lib/migration-runner.d.ts.map +1 -0
- package/dist/lib/migration-runner.js +107 -0
- package/dist/lib/migration-runner.js.map +1 -0
- package/dist/lib/org-managed-db.module.d.ts +29 -0
- package/dist/lib/org-managed-db.module.d.ts.map +1 -0
- package/dist/lib/org-managed-db.module.js +161 -0
- package/dist/lib/org-managed-db.module.js.map +1 -0
- package/dist/lib/prisma-factory.d.ts +13 -0
- package/dist/lib/prisma-factory.d.ts.map +1 -0
- package/dist/lib/prisma-factory.js +57 -0
- package/dist/lib/prisma-factory.js.map +1 -0
- package/dist/lib/types.d.ts +69 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +3 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/lib/url-builder.d.ts +3 -0
- package/dist/lib/url-builder.d.ts.map +1 -0
- package/dist/lib/url-builder.js +51 -0
- package/dist/lib/url-builder.js.map +1 -0
- package/package.json +55 -0
- package/src/index.ts +51 -0
- package/src/lib/migration-discovery.ts +134 -0
- package/src/lib/migration-runner.ts +177 -0
- package/src/lib/org-managed-db.module.ts +187 -0
- package/src/lib/prisma-factory.ts +101 -0
- package/src/lib/types.ts +113 -0
- package/src/lib/url-builder.ts +96 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { Logger } from '@nestjs/common';
|
|
3
|
+
import type { BiomeMigrationOptions, BiomeMigrationResult } from './types';
|
|
4
|
+
|
|
5
|
+
const logger = new Logger('BiomeMigrationRunner');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Runs migrations for a biome using the specified migration system.
|
|
9
|
+
* Supports Prisma, Flyway, Drizzle, and custom runners via CLI.
|
|
10
|
+
*
|
|
11
|
+
* Optionally triggers a backup before running (for safety).
|
|
12
|
+
* Returns migration result with timing and error details.
|
|
13
|
+
*/
|
|
14
|
+
export async function runBiomeMigration(
|
|
15
|
+
options: BiomeMigrationOptions,
|
|
16
|
+
): Promise<BiomeMigrationResult> {
|
|
17
|
+
const {
|
|
18
|
+
databaseUrl,
|
|
19
|
+
migrationsPath,
|
|
20
|
+
migrationType,
|
|
21
|
+
dryRun = false,
|
|
22
|
+
backupBeforeRun = false,
|
|
23
|
+
biomeId = 'unknown',
|
|
24
|
+
orgDatabasePoolApiUrl,
|
|
25
|
+
databaseId,
|
|
26
|
+
} = options;
|
|
27
|
+
|
|
28
|
+
if (!databaseUrl) {
|
|
29
|
+
return {
|
|
30
|
+
success: false,
|
|
31
|
+
migrationsRun: [],
|
|
32
|
+
errors: ['databaseUrl is required'],
|
|
33
|
+
duration: 0,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const startTime = Date.now();
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// Optional: trigger backup before migration
|
|
41
|
+
if (backupBeforeRun && orgDatabasePoolApiUrl && databaseId) {
|
|
42
|
+
const backupId = await triggerBackup(
|
|
43
|
+
orgDatabasePoolApiUrl,
|
|
44
|
+
databaseId,
|
|
45
|
+
biomeId,
|
|
46
|
+
);
|
|
47
|
+
logger.log(`[${biomeId}] Backup triggered: ${backupId}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Run the migrations
|
|
51
|
+
const cmd = buildMigrationCommand(
|
|
52
|
+
migrationType,
|
|
53
|
+
migrationsPath,
|
|
54
|
+
dryRun,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
logger.log(`[${biomeId}] Running: ${cmd}`);
|
|
58
|
+
|
|
59
|
+
const output = execSync(cmd, {
|
|
60
|
+
env: { ...process.env, DATABASE_URL: databaseUrl },
|
|
61
|
+
encoding: 'utf-8',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
logger.log(`[${biomeId}] Migrations completed:\n${output}`);
|
|
65
|
+
|
|
66
|
+
const duration = Date.now() - startTime;
|
|
67
|
+
return {
|
|
68
|
+
success: true,
|
|
69
|
+
migrationsRun: parseAppliedMigrations(output, migrationType),
|
|
70
|
+
duration,
|
|
71
|
+
};
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
74
|
+
const duration = Date.now() - startTime;
|
|
75
|
+
|
|
76
|
+
logger.error(
|
|
77
|
+
`[${biomeId}] Migration failed: ${errorMsg}`,
|
|
78
|
+
err instanceof Error ? err.stack : undefined,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
success: false,
|
|
83
|
+
migrationsRun: [],
|
|
84
|
+
errors: [errorMsg],
|
|
85
|
+
duration,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Builds the CLI command for running migrations based on the migration system.
|
|
92
|
+
*/
|
|
93
|
+
function buildMigrationCommand(
|
|
94
|
+
migrationType: string,
|
|
95
|
+
migrationsPath: string,
|
|
96
|
+
dryRun: boolean,
|
|
97
|
+
): string {
|
|
98
|
+
const dryRunFlag = dryRun ? '--dry-run' : '';
|
|
99
|
+
|
|
100
|
+
switch (migrationType) {
|
|
101
|
+
case 'prisma':
|
|
102
|
+
return `npx prisma migrate deploy ${dryRunFlag}`.trim();
|
|
103
|
+
case 'flyway':
|
|
104
|
+
return `flyway -locations=filesystem:${migrationsPath} migrate ${dryRunFlag}`.trim();
|
|
105
|
+
case 'drizzle':
|
|
106
|
+
return `drizzle-kit migrate --config=drizzle.config.ts ${dryRunFlag}`.trim();
|
|
107
|
+
default:
|
|
108
|
+
throw new Error(`Unsupported migration type: ${migrationType}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parses the output of a migration command to extract applied migration names.
|
|
114
|
+
*/
|
|
115
|
+
function parseAppliedMigrations(output: string, migrationType: string): string[] {
|
|
116
|
+
const migrations: string[] = [];
|
|
117
|
+
|
|
118
|
+
switch (migrationType) {
|
|
119
|
+
case 'prisma': {
|
|
120
|
+
// Prisma format: "Migrate created: 20240101120000_init"
|
|
121
|
+
const matches = output.match(/Migrate created: (\S+)/g) || [];
|
|
122
|
+
matches.forEach((m) => {
|
|
123
|
+
const name = m.replace('Migrate created: ', '');
|
|
124
|
+
migrations.push(name);
|
|
125
|
+
});
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case 'flyway': {
|
|
129
|
+
// Flyway format: "Successfully applied 2 migration(s) ..."
|
|
130
|
+
const matches = output.match(/V\d+__\S+/g) || [];
|
|
131
|
+
migrations.push(...matches);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case 'drizzle': {
|
|
135
|
+
// Drizzle format: varies; look for file names
|
|
136
|
+
const matches = output.match(/\d+_\S+\.sql/g) || [];
|
|
137
|
+
migrations.push(...matches);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return migrations;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Triggers a backup in org-database-pool-api before running migrations.
|
|
147
|
+
* This is a safety measure to allow rollback if needed.
|
|
148
|
+
*/
|
|
149
|
+
async function triggerBackup(
|
|
150
|
+
poolApiUrl: string,
|
|
151
|
+
databaseId: string,
|
|
152
|
+
biomeId: string,
|
|
153
|
+
): Promise<string> {
|
|
154
|
+
try {
|
|
155
|
+
const response = await fetch(
|
|
156
|
+
`${poolApiUrl}/databases/${databaseId}/snapshot`,
|
|
157
|
+
{
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: {
|
|
160
|
+
'Content-Type': 'application/json',
|
|
161
|
+
'X-Biome-Id': biomeId,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(`Backup failed: ${response.statusText}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const data = await response.json() as { backupId: string };
|
|
171
|
+
return data.backupId;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
174
|
+
logger.warn(`[${biomeId}] Failed to trigger backup: ${errorMsg}`);
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { Global, Inject, Injectable, Logger, Module, OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import type { OrgManagedDatabaseOptions, ConnectionGrant } from './types';
|
|
3
|
+
|
|
4
|
+
export const ORG_MANAGED_DATABASE_OPTIONS = 'ORG_MANAGED_DATABASE_OPTIONS';
|
|
5
|
+
|
|
6
|
+
const logger = new Logger('OrgManagedDatabaseModule');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Service that manages connections to org-managed databases via org-database-pool-api.
|
|
10
|
+
*/
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class OrgManagedDatabaseService implements OnModuleInit {
|
|
13
|
+
private refreshInterval?: NodeJS.Timeout;
|
|
14
|
+
private currentGrant?: ConnectionGrant;
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
@Inject(ORG_MANAGED_DATABASE_OPTIONS)
|
|
18
|
+
private readonly options: OrgManagedDatabaseOptions,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
async onModuleInit(): Promise<void> {
|
|
22
|
+
if (!this.options.databaseId) {
|
|
23
|
+
logger.warn('databaseId not provided; org-managed database integration disabled');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await this.refreshConnection();
|
|
28
|
+
|
|
29
|
+
const refreshInterval = this.options.refreshConnInterval || 30_000;
|
|
30
|
+
this.refreshInterval = setInterval(() => {
|
|
31
|
+
this.refreshConnection().catch((err) => {
|
|
32
|
+
logger.error('Failed to refresh org-managed database connection', err);
|
|
33
|
+
});
|
|
34
|
+
}, refreshInterval);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async onModuleDestroy(): Promise<void> {
|
|
38
|
+
if (this.refreshInterval) {
|
|
39
|
+
clearInterval(this.refreshInterval);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getConnectionGrant(): Promise<ConnectionGrant> {
|
|
44
|
+
if (!this.currentGrant) {
|
|
45
|
+
await this.refreshConnection();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const now = new Date();
|
|
49
|
+
const timeToExpiry = this.currentGrant!.expiresAt.getTime() - now.getTime();
|
|
50
|
+
|
|
51
|
+
if (timeToExpiry < 5000) {
|
|
52
|
+
await this.refreshConnection();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return this.currentGrant!;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async getDatabaseUrl(): Promise<string> {
|
|
59
|
+
const grant = await this.getConnectionGrant();
|
|
60
|
+
return grant.databaseUrl;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async refreshConnection(): Promise<void> {
|
|
64
|
+
if (!this.options.databaseId) {
|
|
65
|
+
throw new Error('databaseId is required for org-managed database');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const database = await this.fetchJson<{ schemaName: string; orgId: string }>(
|
|
70
|
+
`${this.options.poolApiUrl}/databases/${this.options.databaseId}`,
|
|
71
|
+
);
|
|
72
|
+
const roleName = this.rolesForSchema(database.schemaName).rwRoleName;
|
|
73
|
+
const grant = await this.fetchJson<{
|
|
74
|
+
host: string;
|
|
75
|
+
port: number;
|
|
76
|
+
database: string;
|
|
77
|
+
schema: string;
|
|
78
|
+
user: string;
|
|
79
|
+
password: string;
|
|
80
|
+
expiresAt: string;
|
|
81
|
+
}>(`${this.options.poolApiUrl}/connections/mint`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
'X-Biome-Id': this.options.biomeId || 'unknown',
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
databaseId: this.options.databaseId,
|
|
89
|
+
schemaName: database.schemaName,
|
|
90
|
+
roleName,
|
|
91
|
+
ttlSeconds: 600,
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
this.currentGrant = {
|
|
96
|
+
databaseUrl: this.buildPostgresUrl({
|
|
97
|
+
host: grant.host,
|
|
98
|
+
port: grant.port,
|
|
99
|
+
database: grant.database,
|
|
100
|
+
user: grant.user,
|
|
101
|
+
password: grant.password,
|
|
102
|
+
schema: grant.schema,
|
|
103
|
+
}),
|
|
104
|
+
host: grant.host,
|
|
105
|
+
port: grant.port,
|
|
106
|
+
database: grant.database,
|
|
107
|
+
schema: grant.schema,
|
|
108
|
+
user: grant.user,
|
|
109
|
+
password: grant.password,
|
|
110
|
+
expiresAt: new Date(grant.expiresAt),
|
|
111
|
+
databaseId: this.options.databaseId,
|
|
112
|
+
schemaName: database.schemaName,
|
|
113
|
+
roleName,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
logger.log(
|
|
117
|
+
`Connection grant refreshed for database ${this.options.databaseId}; expires at ${grant.expiresAt}`,
|
|
118
|
+
);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
logger.error(
|
|
121
|
+
`Failed to refresh org-managed database connection: ${
|
|
122
|
+
err instanceof Error ? err.message : String(err)
|
|
123
|
+
}`,
|
|
124
|
+
);
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private rolesForSchema(schemaName: string): {
|
|
130
|
+
rwRoleName: string;
|
|
131
|
+
roRoleName: string;
|
|
132
|
+
} {
|
|
133
|
+
const base = schemaName.toLowerCase().replace(/[^a-z0-9_]/g, '_').slice(0, 54);
|
|
134
|
+
return {
|
|
135
|
+
rwRoleName: `${base}_rw`,
|
|
136
|
+
roRoleName: `${base}_ro`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private buildPostgresUrl(input: {
|
|
141
|
+
host: string;
|
|
142
|
+
port: number;
|
|
143
|
+
database: string;
|
|
144
|
+
user: string;
|
|
145
|
+
password: string;
|
|
146
|
+
schema: string;
|
|
147
|
+
}): string {
|
|
148
|
+
const user = encodeURIComponent(input.user);
|
|
149
|
+
const password = encodeURIComponent(input.password);
|
|
150
|
+
const schema = encodeURIComponent(input.schema);
|
|
151
|
+
|
|
152
|
+
return `postgresql://${user}:${password}@${input.host}:${input.port}/${input.database}?schema=${schema}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
156
|
+
const response = await fetch(url, init);
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
throw new Error(`Request failed: ${response.status} ${response.statusText} (${url})`);
|
|
159
|
+
}
|
|
160
|
+
return (await response.json()) as T;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@Global()
|
|
165
|
+
@Module({
|
|
166
|
+
providers: [OrgManagedDatabaseService],
|
|
167
|
+
exports: [OrgManagedDatabaseService],
|
|
168
|
+
})
|
|
169
|
+
export class OrgManagedDatabaseModule {
|
|
170
|
+
static register(options: OrgManagedDatabaseOptions) {
|
|
171
|
+
return {
|
|
172
|
+
module: OrgManagedDatabaseModule,
|
|
173
|
+
providers: [
|
|
174
|
+
{
|
|
175
|
+
provide: ORG_MANAGED_DATABASE_OPTIONS,
|
|
176
|
+
useValue: options,
|
|
177
|
+
},
|
|
178
|
+
OrgManagedDatabaseService,
|
|
179
|
+
],
|
|
180
|
+
exports: [OrgManagedDatabaseService],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function hasOrgDatabaseConfig(): boolean {
|
|
186
|
+
return Boolean(process.env.ORG_DATABASE_POOL_API_URL && process.env.ORG_DATABASE_ID);
|
|
187
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
import type { CreateBiomePrismaClientOptions } from './types';
|
|
3
|
+
|
|
4
|
+
const logger = new Logger('BiomePrismaFactory');
|
|
5
|
+
|
|
6
|
+
interface PrismaClientLike {
|
|
7
|
+
$on(event: 'error' | 'warn', handler: (event: { message: string; stack?: string }) => void): void;
|
|
8
|
+
$connect(): Promise<void>;
|
|
9
|
+
$disconnect(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { PrismaClient } = require('@prisma/client') as {
|
|
13
|
+
PrismaClient: new (options: {
|
|
14
|
+
datasources: {
|
|
15
|
+
db: {
|
|
16
|
+
url: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
log: Array<{
|
|
20
|
+
emit: 'event';
|
|
21
|
+
level: 'error' | 'warn' | 'info' | 'debug';
|
|
22
|
+
}>;
|
|
23
|
+
}) => PrismaClientLike;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a Prisma client for a biome with standardized error handling and logging.
|
|
28
|
+
*/
|
|
29
|
+
export function createBiomePrismaClient(
|
|
30
|
+
options: CreateBiomePrismaClientOptions,
|
|
31
|
+
): PrismaClientLike {
|
|
32
|
+
const { databaseUrl, biomeId = 'unknown', schema, logLevel = 'warn' } = options;
|
|
33
|
+
|
|
34
|
+
if (!databaseUrl) {
|
|
35
|
+
throw new Error('[BiomePrismaFactory] databaseUrl is required');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Build datasource URL with schema if provided
|
|
39
|
+
let finalUrl = databaseUrl;
|
|
40
|
+
if (schema && !databaseUrl.includes(`schema=${schema}`)) {
|
|
41
|
+
const separator = databaseUrl.includes('?') ? '&' : '?';
|
|
42
|
+
finalUrl = `${databaseUrl}${separator}schema=${schema}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const client = new PrismaClient({
|
|
46
|
+
datasources: {
|
|
47
|
+
db: {
|
|
48
|
+
url: finalUrl,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
log: [
|
|
52
|
+
{
|
|
53
|
+
emit: 'event',
|
|
54
|
+
level: logLevel as any,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Set up event listeners
|
|
60
|
+
client.$on('error', (event) => {
|
|
61
|
+
logger.error(
|
|
62
|
+
`[${biomeId}] Prisma error: ${event.message}`,
|
|
63
|
+
event.stack,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
client.$on('warn', (event) => {
|
|
68
|
+
logger.warn(`[${biomeId}] Prisma warn: ${event.message}`);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Test the connection on startup
|
|
72
|
+
client.$connect()
|
|
73
|
+
.then(() => {
|
|
74
|
+
const schemaLabel = schema ? `${schema} schema` : 'default schema';
|
|
75
|
+
logger.log(`[${biomeId}] Prisma client connected to ${schemaLabel}`);
|
|
76
|
+
})
|
|
77
|
+
.catch((err) => {
|
|
78
|
+
logger.error(
|
|
79
|
+
`[${biomeId}] Failed to connect Prisma client: ${err.message}`,
|
|
80
|
+
err.stack,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return client;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Gracefully disconnects a Prisma client.
|
|
89
|
+
*/
|
|
90
|
+
export async function disconnectBiomePrismaClient(
|
|
91
|
+
client: PrismaClientLike,
|
|
92
|
+
biomeId: string = 'unknown',
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
try {
|
|
95
|
+
await client.$disconnect();
|
|
96
|
+
logger.log(`[${biomeId}] Prisma client disconnected`);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
99
|
+
logger.error(`[${biomeId}] Failed to disconnect Prisma client: ${message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for the Biome Database SDK.
|
|
3
|
+
* Used by biome API services to initialize, discover, and manage databases.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type MigrationType = 'prisma' | 'flyway' | 'drizzle' | 'custom';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Configuration for a single database in a multi-database setup.
|
|
10
|
+
*/
|
|
11
|
+
export interface DatabaseConfig {
|
|
12
|
+
name: string;
|
|
13
|
+
schema?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Options for ensureDatabaseUrls helper.
|
|
18
|
+
*/
|
|
19
|
+
export interface EnsureDatabaseUrlsOptions {
|
|
20
|
+
singleDatabase?: boolean;
|
|
21
|
+
dbName?: string;
|
|
22
|
+
dbSchema?: string;
|
|
23
|
+
databases?: Record<string, DatabaseConfig>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Options for Prisma client factory.
|
|
28
|
+
*/
|
|
29
|
+
export interface CreateBiomePrismaClientOptions {
|
|
30
|
+
databaseUrl: string;
|
|
31
|
+
biomeId?: string;
|
|
32
|
+
schema?: string;
|
|
33
|
+
logLevel?: 'error' | 'warn' | 'info' | 'debug';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Options for migration discovery.
|
|
38
|
+
*/
|
|
39
|
+
export interface DiscoverMigrationsOptions {
|
|
40
|
+
searchPaths: string[];
|
|
41
|
+
migrationType: MigrationType;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Result of migration discovery.
|
|
46
|
+
*/
|
|
47
|
+
export interface DiscoveredMigrations {
|
|
48
|
+
applied: MigrationRecord[];
|
|
49
|
+
pending: MigrationRecord[];
|
|
50
|
+
errors?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A single migration record.
|
|
55
|
+
*/
|
|
56
|
+
export interface MigrationRecord {
|
|
57
|
+
name: string;
|
|
58
|
+
checksum?: string;
|
|
59
|
+
appliedAt?: Date;
|
|
60
|
+
path: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Options for running a migration.
|
|
65
|
+
*/
|
|
66
|
+
export interface BiomeMigrationOptions {
|
|
67
|
+
databaseUrl: string;
|
|
68
|
+
migrationsPath: string;
|
|
69
|
+
migrationType: MigrationType;
|
|
70
|
+
dryRun?: boolean;
|
|
71
|
+
backupBeforeRun?: boolean;
|
|
72
|
+
biomeId?: string;
|
|
73
|
+
orgDatabasePoolApiUrl?: string;
|
|
74
|
+
databaseId?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Result of running a migration.
|
|
79
|
+
*/
|
|
80
|
+
export interface BiomeMigrationResult {
|
|
81
|
+
success: boolean;
|
|
82
|
+
migrationsRun: string[];
|
|
83
|
+
errors?: string[];
|
|
84
|
+
backupId?: string;
|
|
85
|
+
duration: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Options for org-managed database attachment.
|
|
90
|
+
*/
|
|
91
|
+
export interface OrgManagedDatabaseOptions {
|
|
92
|
+
poolApiUrl: string;
|
|
93
|
+
refreshConnInterval?: number;
|
|
94
|
+
databaseId?: string;
|
|
95
|
+
biomeId?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Connection grant from org-database-pool-api.
|
|
100
|
+
*/
|
|
101
|
+
export interface ConnectionGrant {
|
|
102
|
+
databaseUrl: string;
|
|
103
|
+
host: string;
|
|
104
|
+
port: number;
|
|
105
|
+
database: string;
|
|
106
|
+
schema: string;
|
|
107
|
+
user: string;
|
|
108
|
+
password: string;
|
|
109
|
+
expiresAt: Date;
|
|
110
|
+
databaseId: string;
|
|
111
|
+
schemaName: string;
|
|
112
|
+
roleName: string;
|
|
113
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { DatabaseConfig, EnsureDatabaseUrlsOptions } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ensures DATABASE_URL is set by constructing it from component env vars if needed.
|
|
5
|
+
* Supports both single-database and multi-database biomes.
|
|
6
|
+
*
|
|
7
|
+
* Priority: existing DATABASE_URL > constructed from DB_* vars
|
|
8
|
+
*
|
|
9
|
+
* Throws if incomplete and no DATABASE_URL is already set.
|
|
10
|
+
*/
|
|
11
|
+
export function ensureDatabaseUrls(options: EnsureDatabaseUrlsOptions = {}): void {
|
|
12
|
+
const {
|
|
13
|
+
singleDatabase = true,
|
|
14
|
+
dbName,
|
|
15
|
+
dbSchema,
|
|
16
|
+
databases,
|
|
17
|
+
} = options;
|
|
18
|
+
|
|
19
|
+
if (singleDatabase) {
|
|
20
|
+
ensureSingleDatabase(dbName, dbSchema);
|
|
21
|
+
} else {
|
|
22
|
+
ensureMultiDatabase(databases || {});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Single-database setup. Constructs DATABASE_URL if not already set.
|
|
28
|
+
*/
|
|
29
|
+
function ensureSingleDatabase(dbName?: string, dbSchema?: string): void {
|
|
30
|
+
if (process.env.DATABASE_URL) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
DB_USER,
|
|
36
|
+
DB_PASSWORD,
|
|
37
|
+
DB_HOST,
|
|
38
|
+
DB_PORT,
|
|
39
|
+
DB_NAME,
|
|
40
|
+
DB_SCHEMA,
|
|
41
|
+
DB_SSL_PARAMS,
|
|
42
|
+
} = process.env;
|
|
43
|
+
|
|
44
|
+
const user = DB_USER;
|
|
45
|
+
const password = DB_PASSWORD;
|
|
46
|
+
const host = DB_HOST;
|
|
47
|
+
const port = DB_PORT;
|
|
48
|
+
const name = DB_NAME || dbName || 'xema';
|
|
49
|
+
const schema = DB_SCHEMA || dbSchema;
|
|
50
|
+
const ssl = DB_SSL_PARAMS;
|
|
51
|
+
|
|
52
|
+
if (!user || !password || !host || !port) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
'[BiomeDatabase] DATABASE_URL not set and incomplete DB_* env vars. ' +
|
|
55
|
+
'Set DATABASE_URL or provide DB_USER, DB_PASSWORD, DB_HOST, DB_PORT',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const base = `postgresql://${user}:${encodeURIComponent(password)}@${host}:${port}/${name}`;
|
|
60
|
+
const schemaPart = schema ? `schema=${schema}` : null;
|
|
61
|
+
const params = [schemaPart, ssl].filter(Boolean).join('&');
|
|
62
|
+
process.env.DATABASE_URL = params ? `${base}?${params}` : base;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Multi-database setup. Constructs DATABASE_URL_* env vars for each database.
|
|
67
|
+
*/
|
|
68
|
+
function ensureMultiDatabase(databases: Record<string, DatabaseConfig>): void {
|
|
69
|
+
for (const [key, config] of Object.entries(databases)) {
|
|
70
|
+
const envKey = key === 'main' ? 'DATABASE_URL' : `DATABASE_URL_${key.toUpperCase()}`;
|
|
71
|
+
|
|
72
|
+
if (process.env[envKey]) {
|
|
73
|
+
continue; // already set, skip
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const {
|
|
77
|
+
DB_USER,
|
|
78
|
+
DB_PASSWORD,
|
|
79
|
+
DB_HOST,
|
|
80
|
+
DB_PORT,
|
|
81
|
+
DB_SSL_PARAMS,
|
|
82
|
+
} = process.env;
|
|
83
|
+
|
|
84
|
+
if (!DB_USER || !DB_PASSWORD || !DB_HOST || !DB_PORT) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`[BiomeDatabase] ${envKey} not set and incomplete DB_* env vars. ` +
|
|
87
|
+
'Set DATABASE_URL or provide DB_USER, DB_PASSWORD, DB_HOST, DB_PORT',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const base = `postgresql://${DB_USER}:${encodeURIComponent(DB_PASSWORD)}@${DB_HOST}:${DB_PORT}/${config.name}`;
|
|
92
|
+
const schemaPart = config.schema ? `schema=${config.schema}` : null;
|
|
93
|
+
const params = [schemaPart, DB_SSL_PARAMS].filter(Boolean).join('&');
|
|
94
|
+
process.env[envKey] = params ? `${base}?${params}` : base;
|
|
95
|
+
}
|
|
96
|
+
}
|