@rsdk/db 5.12.0-next.7 → 5.12.0-next.8

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 (35) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +10 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/multi-host-url/exception.d.ts +18 -0
  5. package/dist/multi-host-url/exception.js +43 -0
  6. package/dist/multi-host-url/exception.js.map +1 -0
  7. package/dist/multi-host-url/index.d.ts +10 -0
  8. package/dist/multi-host-url/index.js +18 -0
  9. package/dist/multi-host-url/index.js.map +1 -0
  10. package/dist/multi-host-url/multi-host-url.d.ts +37 -0
  11. package/dist/multi-host-url/multi-host-url.js +58 -0
  12. package/dist/multi-host-url/multi-host-url.js.map +1 -0
  13. package/dist/multi-host-url/multi-host-url.parser.d.ts +26 -0
  14. package/dist/multi-host-url/multi-host-url.parser.js +151 -0
  15. package/dist/multi-host-url/multi-host-url.parser.js.map +1 -0
  16. package/dist/multi-host-url/multi-host-url.resolver.d.ts +25 -0
  17. package/dist/multi-host-url/multi-host-url.resolver.js +49 -0
  18. package/dist/multi-host-url/multi-host-url.resolver.js.map +1 -0
  19. package/dist/multi-host-url/pg-native.d.ts +54 -0
  20. package/dist/multi-host-url/pg-native.js +90 -0
  21. package/dist/multi-host-url/pg-native.js.map +1 -0
  22. package/dist/multi-host-url/resolve-db-connection.d.ts +64 -0
  23. package/dist/multi-host-url/resolve-db-connection.js +57 -0
  24. package/dist/multi-host-url/resolve-db-connection.js.map +1 -0
  25. package/jest.config.unit.js +1 -0
  26. package/package.json +3 -2
  27. package/src/index.ts +18 -0
  28. package/src/multi-host-url/exception.ts +41 -0
  29. package/src/multi-host-url/index.ts +17 -0
  30. package/src/multi-host-url/multi-host-url.parser.spec.ts +131 -0
  31. package/src/multi-host-url/multi-host-url.parser.ts +182 -0
  32. package/src/multi-host-url/multi-host-url.resolver.ts +57 -0
  33. package/src/multi-host-url/multi-host-url.ts +85 -0
  34. package/src/multi-host-url/pg-native.ts +133 -0
  35. package/src/multi-host-url/resolve-db-connection.ts +127 -0
@@ -0,0 +1,57 @@
1
+ import type { ILogger } from '@rsdk/logging';
2
+
3
+ import type { MultiHostUrl } from './multi-host-url';
4
+
5
+ export type MultiHostProbe = (candidate: URL) => Promise<void>;
6
+
7
+ /**
8
+ * Стратегия "resolve at bootstrap": перебирает хосты из multi-host URL и
9
+ * выбирает первый, на котором проба прошла успешно. Если ни один не отвечает,
10
+ * возвращается первый хост — далее у каждого ORM работает свой reconnect-цикл.
11
+ *
12
+ * **Bootstrap-only failover.** Чистый JS-драйвер `pg` не реализует
13
+ * libpq-style failover, поэтому при использовании этого resolver-а
14
+ * переключение между хостами происходит **только** в момент старта.
15
+ * Если уже выбранный хост упадёт в runtime, reconnect-цикл ORM продолжит
16
+ * подключаться к нему же — до перезапуска приложения.
17
+ *
18
+ * Для **runtime failover** (libpq-style) используйте {@link resolveDbConnection}
19
+ * — он автоматически переключается на native-libpq путь, когда хостов >1
20
+ * и доступен `pg-native`. `MultiHostResolver` остаётся низкоуровневым
21
+ * примитивом для случаев, где native путь невозможен (не-postgres драйверы)
22
+ * или нежелателен (например, чтобы не зависеть от системной libpq).
23
+ */
24
+ export class MultiHostResolver {
25
+ constructor(private readonly logger: ILogger) {}
26
+
27
+ async resolve(url: MultiHostUrl, probe: MultiHostProbe): Promise<URL> {
28
+ if (url.hosts.length === 1) {
29
+ return url.getHostAt(0);
30
+ }
31
+
32
+ for (let i = 0; i < url.hosts.length; i++) {
33
+ const candidate = url.getHostAt(i);
34
+ const entry = url.hosts[i];
35
+
36
+ try {
37
+ await probe(candidate);
38
+ if (i > 0) {
39
+ this.logger.info(
40
+ `multi-host db: connected to host #${i + 1} (${entry.host}:${entry.port}) after previous hosts failed`,
41
+ );
42
+ }
43
+ return candidate;
44
+ } catch (error) {
45
+ this.logger.warn(
46
+ `multi-host db: host ${entry.host}:${entry.port} not reachable, trying next`,
47
+ error as Error,
48
+ );
49
+ }
50
+ }
51
+
52
+ this.logger.warn(
53
+ 'multi-host db: none of the configured hosts responded to the bootstrap probe; falling back to the first host and relying on reconnect cycle',
54
+ );
55
+ return url.getHostAt(0);
56
+ }
57
+ }
@@ -0,0 +1,85 @@
1
+ export interface MultiHostEntry {
2
+ host: string;
3
+
4
+ /**
5
+ * Порт хоста, если он явно указан в URL. Если `undefined` — порт в
6
+ * исходном URL отсутствует; вызывающая сторона (ORM-плагин / драйвер) сама
7
+ * подставляет дефолтное значение для своей СУБД (`5432` для PG,
8
+ * `3306` для MySQL и т.п.).
9
+ */
10
+ port?: number;
11
+ }
12
+
13
+ export interface MultiHostUrlInit {
14
+ protocol: string;
15
+ username?: string;
16
+ password?: string;
17
+ hosts: MultiHostEntry[];
18
+ database?: string;
19
+ searchParams: URLSearchParams;
20
+ }
21
+
22
+ export class MultiHostUrl {
23
+ readonly protocol: string;
24
+ readonly username?: string;
25
+ readonly password?: string;
26
+ readonly hosts: readonly MultiHostEntry[];
27
+ readonly database?: string;
28
+ readonly searchParams: URLSearchParams;
29
+
30
+ constructor(init: MultiHostUrlInit) {
31
+ this.protocol = init.protocol;
32
+ this.hosts = init.hosts;
33
+ this.searchParams = init.searchParams;
34
+ if (init.username !== undefined) this.username = init.username;
35
+ if (init.password !== undefined) this.password = init.password;
36
+ if (init.database !== undefined) this.database = init.database;
37
+ }
38
+
39
+ /**
40
+ * Single-host URL для передачи в драйвер БД (pg, knex, typeorm, mikro-orm).
41
+ * Драйверы не понимают multihost-синтаксис в connection string, поэтому
42
+ * перед бутстрапом нужно выбрать один живой хост из списка.
43
+ */
44
+ getHostAt(index: number): URL {
45
+ const entry = this.hosts[index];
46
+
47
+ if (!entry) {
48
+ throw new RangeError(
49
+ `host index ${index} out of bounds (hosts.length=${this.hosts.length})`,
50
+ );
51
+ }
52
+
53
+ const auth = this.username
54
+ ? `${encodeURIComponent(this.username)}${this.password ? `:${encodeURIComponent(this.password)}` : ''}@`
55
+ : '';
56
+ const search = this.searchParams.toString();
57
+ const query = search ? `?${search}` : '';
58
+ const database = this.database ? `/${this.database}` : '';
59
+
60
+ const port = entry.port === undefined ? '' : `:${entry.port}`;
61
+
62
+ return new URL(
63
+ `${this.protocol}//${auth}${entry.host}${port}${database}${query}`,
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Возвращает оригинальный multi-host URL (или single-host, если хост один).
69
+ */
70
+ toString(): string {
71
+ const auth = this.username
72
+ ? `${encodeURIComponent(this.username)}${this.password ? `:${encodeURIComponent(this.password)}` : ''}@`
73
+ : '';
74
+ const hosts = this.hosts
75
+ .map((entry) =>
76
+ entry.port === undefined ? entry.host : `${entry.host}:${entry.port}`,
77
+ )
78
+ .join(',');
79
+ const search = this.searchParams.toString();
80
+ const query = search ? `?${search}` : '';
81
+ const database = this.database ? `/${this.database}` : '';
82
+
83
+ return `${this.protocol}//${auth}${hosts}${database}${query}`;
84
+ }
85
+ }
@@ -0,0 +1,133 @@
1
+ /* eslint-disable @typescript-eslint/no-require-imports, import/no-extraneous-dependencies */
2
+
3
+ /**
4
+ * Утилиты для работы с `pg` и `pg.native` (libpq) на стороне @rsdk/db.
5
+ * `pg` — peer-зависимость использующего пакета; здесь оборачиваем `require`
6
+ * в try/catch, чтобы пакет, не использующий postgres, не падал при загрузке.
7
+ */
8
+
9
+ export type PgClient = {
10
+ connect(): Promise<void>;
11
+ query(text: string): Promise<unknown>;
12
+ end(): Promise<void>;
13
+ };
14
+
15
+ export type PgClientCtor = new (config: {
16
+ connectionString?: string;
17
+
18
+ /**
19
+ * Escape hatch у `pg.native`: строка идёт напрямую в libpq, минуя
20
+ * `pg-connection-string` (он парсит URL через `new URL()` и падает на
21
+ * multi-host синтаксис).
22
+ * @see node_modules/pg/lib/native/client.js
23
+ */
24
+ nativeConnectionString?: string;
25
+ ssl?: unknown;
26
+ connectionTimeoutMillis?: number;
27
+ }) => PgClient;
28
+
29
+ /**
30
+ * Объект-модуль `pg.native`. TypeORM/Knex/MikroORM принимают его как
31
+ * `driver`/`overrideConfig.driver`, поэтому signature намеренно широкий.
32
+ */
33
+ export type PgNativeModule = {
34
+ Client: PgClientCtor;
35
+ [key: string]: unknown;
36
+ };
37
+
38
+ type PgModule = {
39
+ Client: PgClientCtor;
40
+ native?: PgNativeModule | null;
41
+ };
42
+
43
+ let cachedPg: PgModule | null = null;
44
+ let cachedNative: PgNativeModule | null = null;
45
+
46
+ const loadPg = (): PgModule => {
47
+ if (cachedPg) return cachedPg;
48
+ try {
49
+ cachedPg = require('pg') as PgModule;
50
+ return cachedPg;
51
+ } catch (error) {
52
+ throw new Error(
53
+ '`pg` package is not installed. Install it as a runtime dependency to use postgres connectivity.',
54
+ { cause: error as Error },
55
+ );
56
+ }
57
+ };
58
+
59
+ /**
60
+ * Пробует загрузить `pg.native` (биндинги к libpq). Доступ к `pg.native`
61
+ * лениво требует пакет `pg-native`; если его нет, возвращаем `null`, чтобы
62
+ * вызывающая сторона могла переключиться на bootstrap-resolve вместо
63
+ * fatal-ошибки.
64
+ */
65
+ export const loadPgNative = (): PgNativeModule | null => {
66
+ if (cachedNative) return cachedNative;
67
+
68
+ const pg = loadPg();
69
+ let nativeNs: PgNativeModule | null | undefined;
70
+
71
+ try {
72
+ nativeNs = pg.native;
73
+ } catch {
74
+ return null;
75
+ }
76
+ if (!nativeNs?.Client) {
77
+ return null;
78
+ }
79
+
80
+ cachedNative = nativeNs;
81
+ return nativeNs;
82
+ };
83
+
84
+ /**
85
+ * Bootstrap probe одного хоста: connects, runs `SELECT 1`, disconnects.
86
+ * Подходит как `bootstrapProbe` в {@link resolveDbConnection} для
87
+ * postgres-семейства драйверов.
88
+ */
89
+ export const pgProbe = async (candidate: URL, ssl: unknown): Promise<void> => {
90
+ const Client = loadPg().Client;
91
+ const client = new Client({
92
+ connectionString: candidate.toString(),
93
+ ssl,
94
+ connectionTimeoutMillis: 5_000,
95
+ });
96
+
97
+ await client.connect();
98
+ try {
99
+ await client.query('SELECT 1');
100
+ } finally {
101
+ await client.end().catch(() => {
102
+ /* swallow */
103
+ });
104
+ }
105
+ };
106
+
107
+ /**
108
+ * Bootstrap probe для multi-host URL через нативный libpq-клиент.
109
+ * libpq сам перебирает хосты внутри connection-string, поэтому достаточно
110
+ * одного `SELECT 1`: если ни один хост не доступен — упадёт здесь, fail-fast.
111
+ *
112
+ * Multi-host строка передаётся через `nativeConnectionString`, чтобы
113
+ * обойти JS-парсер `pg-connection-string` (он падает на multi-host URI).
114
+ * SSL-опции в native-режиме задаются параметрами libpq в самой строке
115
+ * (`sslmode=`, `sslrootcert=`, ...), JS-объект `ssl` не используется.
116
+ */
117
+ export const pgProbeMultiHost = async (
118
+ multiHostUrl: string,
119
+ Client: PgClientCtor,
120
+ ): Promise<void> => {
121
+ const client = new Client({
122
+ nativeConnectionString: multiHostUrl,
123
+ });
124
+
125
+ await client.connect();
126
+ try {
127
+ await client.query('SELECT 1');
128
+ } finally {
129
+ await client.end().catch(() => {
130
+ /* swallow */
131
+ });
132
+ }
133
+ };
@@ -0,0 +1,127 @@
1
+ import type { ILogger } from '@rsdk/logging';
2
+
3
+ import { MultiHostNativeRequired } from './exception';
4
+ import type { MultiHostEntry, MultiHostUrl } from './multi-host-url';
5
+ import type { MultiHostProbe } from './multi-host-url.resolver';
6
+ import { MultiHostResolver } from './multi-host-url.resolver';
7
+ import type { PgNativeModule } from './pg-native';
8
+ import { loadPgNative, pgProbeMultiHost } from './pg-native';
9
+
10
+ /**
11
+ * Опции стратегии установки соединения для multi-host URL.
12
+ */
13
+ export interface ResolveDbConnectionOptions {
14
+ url: MultiHostUrl;
15
+ logger: ILogger;
16
+
17
+ /**
18
+ * Включает попытку native-libpq пути. Должен быть `true` только для
19
+ * wire-совместимых с PostgreSQL драйверов (`postgres`, `aurora-postgres`,
20
+ * cockroachdb и т.п.). Для mysql/mssql/sqlite оставлять `false`.
21
+ */
22
+ isPostgresFamily: boolean;
23
+
24
+ /**
25
+ * Probe для bootstrap-resolve пути (выбор живого хоста при старте).
26
+ * Передача probe одновременно служит **разрешением** на bootstrap-фолбэк:
27
+ * если хостов несколько, postgres-семейство и `pg-native` не установлен —
28
+ * resolver упадёт с {@link MultiHostNativeRequired}, если probe не задан,
29
+ * либо выберет живой хост через probe, если задан. Так что не передавайте
30
+ * probe, если хотите fail-fast при отсутствии libpq runtime failover.
31
+ * Для single-host URL probe не вызывается.
32
+ */
33
+ bootstrapProbe?: MultiHostProbe;
34
+ }
35
+
36
+ /**
37
+ * Результат выбора стратегии: либо native-libpq (runtime failover через
38
+ * libpq), либо single-host (bootstrap-выбор живого хоста, без runtime
39
+ * failover). Плагины разворачивают этот union в свой driver-специфичный
40
+ * config.
41
+ */
42
+ export type ResolvedDbConnection =
43
+ | {
44
+ kind: 'native-libpq';
45
+
46
+ /** Оригинальная multi-host строка для libpq. */
47
+ multiHostUrl: string;
48
+
49
+ /**
50
+ * Первый хост из списка. Используется как «косметический» host/port
51
+ * для тех мест в драйверах, что обращаются к этим полям (логи,
52
+ * метрики); реальный коннект libpq собирает из `multiHostUrl`.
53
+ */
54
+ firstHostEntry: MultiHostEntry;
55
+ username?: string;
56
+ password?: string;
57
+ database?: string;
58
+ driver: PgNativeModule;
59
+ }
60
+ | {
61
+ kind: 'single-host';
62
+ url: URL;
63
+ };
64
+
65
+ /**
66
+ * Выбирает стратегию подключения к БД для multi-host URL.
67
+ *
68
+ * - Один хост → возвращаем его без probe.
69
+ * - Несколько хостов + postgres-семейство + `pg-native` установлен →
70
+ * native-libpq путь с runtime failover.
71
+ * - Несколько хостов + postgres-семейство + НЕТ `pg-native`:
72
+ * - `bootstrapProbe` передан → warn + bootstrap-resolve.
73
+ * - `bootstrapProbe` НЕ передан → бросаем {@link MultiHostNativeRequired}.
74
+ * - Несколько хостов + не postgres → bootstrap-resolve через `bootstrapProbe`
75
+ * (если probe не передан, берётся первый хост без проверки доступности).
76
+ */
77
+ export const resolveDbConnection = async (
78
+ options: ResolveDbConnectionOptions,
79
+ ): Promise<ResolvedDbConnection> => {
80
+ const isMultiHost = options.url.hosts.length > 1;
81
+ const native =
82
+ isMultiHost && options.isPostgresFamily ? loadPgNative() : null;
83
+
84
+ if (native) {
85
+ const multiHostUrl = options.url.toString();
86
+
87
+ await pgProbeMultiHost(multiHostUrl, native.Client);
88
+ options.logger.warn(
89
+ `multi-host db: switched to libpq failover via pg.native across ${options.url.hosts.length} hosts. ` +
90
+ 'Caveat: SSL options from config (DB_SSL_MODE / DB_TLS_*) are NOT applied in native mode — ' +
91
+ 'pass `sslmode=`/`sslrootcert=`/`sslcert=`/`sslkey=` in the connection string instead. ' +
92
+ 'See docs/md/DATABASES.md for details.',
93
+ );
94
+
95
+ return {
96
+ kind: 'native-libpq',
97
+ multiHostUrl,
98
+ firstHostEntry: options.url.hosts[0],
99
+ ...(options.url.username && { username: options.url.username }),
100
+ ...(options.url.password && { password: options.url.password }),
101
+ ...(options.url.database && { database: options.url.database }),
102
+ driver: native,
103
+ };
104
+ }
105
+
106
+ if (isMultiHost && options.isPostgresFamily) {
107
+ if (!options.bootstrapProbe) {
108
+ throw new MultiHostNativeRequired(options.url.hosts.length);
109
+ }
110
+ options.logger.warn(
111
+ `multi-host db: \`pg-native\` is not installed — falling back to bootstrap-resolve across ${options.url.hosts.length} hosts. ` +
112
+ 'Runtime failover after a connection drop will NOT work; only the host picked at startup is used. ' +
113
+ 'Install `pg-native` (and a system libpq) for libpq-driven runtime failover.',
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Single-host: пробу не вызываем; для multi-host без probe мы уже
119
+ * упали выше, поэтому здесь probe гарантированно есть либо хост один.
120
+ */
121
+ const probe: MultiHostProbe =
122
+ options.bootstrapProbe ?? ((): Promise<void> => Promise.resolve());
123
+ const resolver = new MultiHostResolver(options.logger);
124
+ const resolved = await resolver.resolve(options.url, probe);
125
+
126
+ return { kind: 'single-host', url: resolved };
127
+ };