@rsdk/db 5.12.0-next.7 → 5.12.0-next.9
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/dist/index.d.ts +2 -0
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/multi-host-url/exception.d.ts +18 -0
- package/dist/multi-host-url/exception.js +48 -0
- package/dist/multi-host-url/exception.js.map +1 -0
- package/dist/multi-host-url/index.d.ts +10 -0
- package/dist/multi-host-url/index.js +18 -0
- package/dist/multi-host-url/index.js.map +1 -0
- package/dist/multi-host-url/multi-host-url.d.ts +37 -0
- package/dist/multi-host-url/multi-host-url.js +58 -0
- package/dist/multi-host-url/multi-host-url.js.map +1 -0
- package/dist/multi-host-url/multi-host-url.parser.d.ts +26 -0
- package/dist/multi-host-url/multi-host-url.parser.js +151 -0
- package/dist/multi-host-url/multi-host-url.parser.js.map +1 -0
- package/dist/multi-host-url/multi-host-url.resolver.d.ts +25 -0
- package/dist/multi-host-url/multi-host-url.resolver.js +50 -0
- package/dist/multi-host-url/multi-host-url.resolver.js.map +1 -0
- package/dist/multi-host-url/pg-native.d.ts +54 -0
- package/dist/multi-host-url/pg-native.js +90 -0
- package/dist/multi-host-url/pg-native.js.map +1 -0
- package/dist/multi-host-url/resolve-db-connection.d.ts +64 -0
- package/dist/multi-host-url/resolve-db-connection.js +57 -0
- package/dist/multi-host-url/resolve-db-connection.js.map +1 -0
- package/jest.config.unit.js +1 -0
- package/package.json +3 -2
- package/src/index.ts +18 -0
- package/src/multi-host-url/exception.ts +48 -0
- package/src/multi-host-url/index.ts +17 -0
- package/src/multi-host-url/multi-host-url.parser.spec.ts +131 -0
- package/src/multi-host-url/multi-host-url.parser.ts +182 -0
- package/src/multi-host-url/multi-host-url.resolver.ts +59 -0
- package/src/multi-host-url/multi-host-url.ts +85 -0
- package/src/multi-host-url/pg-native.ts +133 -0
- package/src/multi-host-url/resolve-db-connection.ts +127 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-require-imports, import/no-extraneous-dependencies */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.pgProbeMultiHost = exports.pgProbe = exports.loadPgNative = void 0;
|
|
5
|
+
let cachedPg = null;
|
|
6
|
+
let cachedNative = null;
|
|
7
|
+
const loadPg = () => {
|
|
8
|
+
if (cachedPg)
|
|
9
|
+
return cachedPg;
|
|
10
|
+
try {
|
|
11
|
+
cachedPg = require('pg');
|
|
12
|
+
return cachedPg;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
throw new Error('`pg` package is not installed. Install it as a runtime dependency to use postgres connectivity.', { cause: error });
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Пробует загрузить `pg.native` (биндинги к libpq). Доступ к `pg.native`
|
|
20
|
+
* лениво требует пакет `pg-native`; если его нет, возвращаем `null`, чтобы
|
|
21
|
+
* вызывающая сторона могла переключиться на bootstrap-resolve вместо
|
|
22
|
+
* fatal-ошибки.
|
|
23
|
+
*/
|
|
24
|
+
const loadPgNative = () => {
|
|
25
|
+
if (cachedNative)
|
|
26
|
+
return cachedNative;
|
|
27
|
+
const pg = loadPg();
|
|
28
|
+
let nativeNs;
|
|
29
|
+
try {
|
|
30
|
+
nativeNs = pg.native;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (!nativeNs?.Client) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
cachedNative = nativeNs;
|
|
39
|
+
return nativeNs;
|
|
40
|
+
};
|
|
41
|
+
exports.loadPgNative = loadPgNative;
|
|
42
|
+
/**
|
|
43
|
+
* Bootstrap probe одного хоста: connects, runs `SELECT 1`, disconnects.
|
|
44
|
+
* Подходит как `bootstrapProbe` в {@link resolveDbConnection} для
|
|
45
|
+
* postgres-семейства драйверов.
|
|
46
|
+
*/
|
|
47
|
+
const pgProbe = async (candidate, ssl) => {
|
|
48
|
+
const Client = loadPg().Client;
|
|
49
|
+
const client = new Client({
|
|
50
|
+
connectionString: candidate.toString(),
|
|
51
|
+
ssl,
|
|
52
|
+
connectionTimeoutMillis: 5_000,
|
|
53
|
+
});
|
|
54
|
+
await client.connect();
|
|
55
|
+
try {
|
|
56
|
+
await client.query('SELECT 1');
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await client.end().catch(() => {
|
|
60
|
+
/* swallow */
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
exports.pgProbe = pgProbe;
|
|
65
|
+
/**
|
|
66
|
+
* Bootstrap probe для multi-host URL через нативный libpq-клиент.
|
|
67
|
+
* libpq сам перебирает хосты внутри connection-string, поэтому достаточно
|
|
68
|
+
* одного `SELECT 1`: если ни один хост не доступен — упадёт здесь, fail-fast.
|
|
69
|
+
*
|
|
70
|
+
* Multi-host строка передаётся через `nativeConnectionString`, чтобы
|
|
71
|
+
* обойти JS-парсер `pg-connection-string` (он падает на multi-host URI).
|
|
72
|
+
* SSL-опции в native-режиме задаются параметрами libpq в самой строке
|
|
73
|
+
* (`sslmode=`, `sslrootcert=`, ...), JS-объект `ssl` не используется.
|
|
74
|
+
*/
|
|
75
|
+
const pgProbeMultiHost = async (multiHostUrl, Client) => {
|
|
76
|
+
const client = new Client({
|
|
77
|
+
nativeConnectionString: multiHostUrl,
|
|
78
|
+
});
|
|
79
|
+
await client.connect();
|
|
80
|
+
try {
|
|
81
|
+
await client.query('SELECT 1');
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await client.end().catch(() => {
|
|
85
|
+
/* swallow */
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
exports.pgProbeMultiHost = pgProbeMultiHost;
|
|
90
|
+
//# sourceMappingURL=pg-native.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pg-native.js","sourceRoot":"","sources":["../../src/multi-host-url/pg-native.ts"],"names":[],"mappings":";AAAA,6FAA6F;;;AA0C7F,IAAI,QAAQ,GAAoB,IAAI,CAAC;AACrC,IAAI,YAAY,GAA0B,IAAI,CAAC;AAE/C,MAAM,MAAM,GAAG,GAAa,EAAE;IAC5B,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC9B,IAAI,CAAC;QACH,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAa,CAAC;QACrC,OAAO,QAAQ,CAAC;IAClB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CACb,iGAAiG,EACjG,EAAE,KAAK,EAAE,KAAc,EAAE,CAC1B,CAAC;IACJ,CAAC;AACH,CAAC,CAAC;AAEF;;;;;GAKG;AACI,MAAM,YAAY,GAAG,GAA0B,EAAE;IACtD,IAAI,YAAY;QAAE,OAAO,YAAY,CAAC;IAEtC,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC;IACpB,IAAI,QAA2C,CAAC;IAEhD,IAAI,CAAC;QACH,QAAQ,GAAG,EAAE,CAAC,MAAM,CAAC;IACvB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;QACtB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,YAAY,GAAG,QAAQ,CAAC;IACxB,OAAO,QAAQ,CAAC;AAClB,CAAC,CAAC;AAjBW,QAAA,YAAY,gBAiBvB;AAEF;;;;GAIG;AACI,MAAM,OAAO,GAAG,KAAK,EAAE,SAAc,EAAE,GAAY,EAAiB,EAAE;IAC3E,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,MAAM,CAAC;IAC/B,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC;QACxB,gBAAgB,EAAE,SAAS,CAAC,QAAQ,EAAE;QACtC,GAAG;QACH,uBAAuB,EAAE,KAAK;KAC/B,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,OAAO,EAAE,CAAC;IACvB,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IACjC,CAAC;YAAS,CAAC;QACT,MAAM,MAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE;YAC5B,aAAa;QACf,CAAC,CAAC,CAAC;IACL,CAAC;AACH,CAAC,CAAC;AAhBW,QAAA,OAAO,WAgBlB;AAEF;;;;;;;;;GASG;AACI,MAAM,gBAAgB,GAAG,KAAK,EACnC,YAAoB,EACpB,MAAoB,EACL,EAAE;IACjB,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC;QACxB,sBAAsB,EAAE,YAAY;KACrC,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,OAAO,EAAE,CAAC;IACvB,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IACjC,CAAC;YAAS,CAAC;QACT,MAAM,MAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE;YAC5B,aAAa;QACf,CAAC,CAAC,CAAC;IACL,CAAC;AACH,CAAC,CAAC;AAhBW,QAAA,gBAAgB,oBAgB3B"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ILogger } from '@rsdk/logging';
|
|
2
|
+
import type { MultiHostEntry, MultiHostUrl } from './multi-host-url';
|
|
3
|
+
import type { MultiHostProbe } from './multi-host-url.resolver';
|
|
4
|
+
import type { PgNativeModule } from './pg-native';
|
|
5
|
+
/**
|
|
6
|
+
* Опции стратегии установки соединения для multi-host URL.
|
|
7
|
+
*/
|
|
8
|
+
export interface ResolveDbConnectionOptions {
|
|
9
|
+
url: MultiHostUrl;
|
|
10
|
+
logger: ILogger;
|
|
11
|
+
/**
|
|
12
|
+
* Включает попытку native-libpq пути. Должен быть `true` только для
|
|
13
|
+
* wire-совместимых с PostgreSQL драйверов (`postgres`, `aurora-postgres`,
|
|
14
|
+
* cockroachdb и т.п.). Для mysql/mssql/sqlite оставлять `false`.
|
|
15
|
+
*/
|
|
16
|
+
isPostgresFamily: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Probe для bootstrap-resolve пути (выбор живого хоста при старте).
|
|
19
|
+
* Передача probe одновременно служит **разрешением** на bootstrap-фолбэк:
|
|
20
|
+
* если хостов несколько, postgres-семейство и `pg-native` не установлен —
|
|
21
|
+
* resolver упадёт с {@link MultiHostNativeRequired}, если probe не задан,
|
|
22
|
+
* либо выберет живой хост через probe, если задан. Так что не передавайте
|
|
23
|
+
* probe, если хотите fail-fast при отсутствии libpq runtime failover.
|
|
24
|
+
* Для single-host URL probe не вызывается.
|
|
25
|
+
*/
|
|
26
|
+
bootstrapProbe?: MultiHostProbe;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Результат выбора стратегии: либо native-libpq (runtime failover через
|
|
30
|
+
* libpq), либо single-host (bootstrap-выбор живого хоста, без runtime
|
|
31
|
+
* failover). Плагины разворачивают этот union в свой driver-специфичный
|
|
32
|
+
* config.
|
|
33
|
+
*/
|
|
34
|
+
export type ResolvedDbConnection = {
|
|
35
|
+
kind: 'native-libpq';
|
|
36
|
+
/** Оригинальная multi-host строка для libpq. */
|
|
37
|
+
multiHostUrl: string;
|
|
38
|
+
/**
|
|
39
|
+
* Первый хост из списка. Используется как «косметический» host/port
|
|
40
|
+
* для тех мест в драйверах, что обращаются к этим полям (логи,
|
|
41
|
+
* метрики); реальный коннект libpq собирает из `multiHostUrl`.
|
|
42
|
+
*/
|
|
43
|
+
firstHostEntry: MultiHostEntry;
|
|
44
|
+
username?: string;
|
|
45
|
+
password?: string;
|
|
46
|
+
database?: string;
|
|
47
|
+
driver: PgNativeModule;
|
|
48
|
+
} | {
|
|
49
|
+
kind: 'single-host';
|
|
50
|
+
url: URL;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Выбирает стратегию подключения к БД для multi-host URL.
|
|
54
|
+
*
|
|
55
|
+
* - Один хост → возвращаем его без probe.
|
|
56
|
+
* - Несколько хостов + postgres-семейство + `pg-native` установлен →
|
|
57
|
+
* native-libpq путь с runtime failover.
|
|
58
|
+
* - Несколько хостов + postgres-семейство + НЕТ `pg-native`:
|
|
59
|
+
* - `bootstrapProbe` передан → warn + bootstrap-resolve.
|
|
60
|
+
* - `bootstrapProbe` НЕ передан → бросаем {@link MultiHostNativeRequired}.
|
|
61
|
+
* - Несколько хостов + не postgres → bootstrap-resolve через `bootstrapProbe`
|
|
62
|
+
* (если probe не передан, берётся первый хост без проверки доступности).
|
|
63
|
+
*/
|
|
64
|
+
export declare const resolveDbConnection: (options: ResolveDbConnectionOptions) => Promise<ResolvedDbConnection>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveDbConnection = void 0;
|
|
4
|
+
const exception_1 = require("./exception");
|
|
5
|
+
const multi_host_url_resolver_1 = require("./multi-host-url.resolver");
|
|
6
|
+
const pg_native_1 = require("./pg-native");
|
|
7
|
+
/**
|
|
8
|
+
* Выбирает стратегию подключения к БД для multi-host URL.
|
|
9
|
+
*
|
|
10
|
+
* - Один хост → возвращаем его без probe.
|
|
11
|
+
* - Несколько хостов + postgres-семейство + `pg-native` установлен →
|
|
12
|
+
* native-libpq путь с runtime failover.
|
|
13
|
+
* - Несколько хостов + postgres-семейство + НЕТ `pg-native`:
|
|
14
|
+
* - `bootstrapProbe` передан → warn + bootstrap-resolve.
|
|
15
|
+
* - `bootstrapProbe` НЕ передан → бросаем {@link MultiHostNativeRequired}.
|
|
16
|
+
* - Несколько хостов + не postgres → bootstrap-resolve через `bootstrapProbe`
|
|
17
|
+
* (если probe не передан, берётся первый хост без проверки доступности).
|
|
18
|
+
*/
|
|
19
|
+
const resolveDbConnection = async (options) => {
|
|
20
|
+
const isMultiHost = options.url.hosts.length > 1;
|
|
21
|
+
const native = isMultiHost && options.isPostgresFamily ? (0, pg_native_1.loadPgNative)() : null;
|
|
22
|
+
if (native) {
|
|
23
|
+
const multiHostUrl = options.url.toString();
|
|
24
|
+
await (0, pg_native_1.pgProbeMultiHost)(multiHostUrl, native.Client);
|
|
25
|
+
options.logger.warn(`multi-host db: switched to libpq failover via pg.native across ${options.url.hosts.length} hosts. ` +
|
|
26
|
+
'Caveat: SSL options from config (DB_SSL_MODE / DB_TLS_*) are NOT applied in native mode — ' +
|
|
27
|
+
'pass `sslmode=`/`sslrootcert=`/`sslcert=`/`sslkey=` in the connection string instead. ' +
|
|
28
|
+
'See docs/md/DATABASES.md for details.');
|
|
29
|
+
return {
|
|
30
|
+
kind: 'native-libpq',
|
|
31
|
+
multiHostUrl,
|
|
32
|
+
firstHostEntry: options.url.hosts[0],
|
|
33
|
+
...(options.url.username && { username: options.url.username }),
|
|
34
|
+
...(options.url.password && { password: options.url.password }),
|
|
35
|
+
...(options.url.database && { database: options.url.database }),
|
|
36
|
+
driver: native,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (isMultiHost && options.isPostgresFamily) {
|
|
40
|
+
if (!options.bootstrapProbe) {
|
|
41
|
+
throw new exception_1.MultiHostNativeRequired(options.url.hosts.length);
|
|
42
|
+
}
|
|
43
|
+
options.logger.warn(`multi-host db: \`pg-native\` is not installed — falling back to bootstrap-resolve across ${options.url.hosts.length} hosts. ` +
|
|
44
|
+
'Runtime failover after a connection drop will NOT work; only the host picked at startup is used. ' +
|
|
45
|
+
'Install `pg-native` (and a system libpq) for libpq-driven runtime failover.');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Single-host: пробу не вызываем; для multi-host без probe мы уже
|
|
49
|
+
* упали выше, поэтому здесь probe гарантированно есть либо хост один.
|
|
50
|
+
*/
|
|
51
|
+
const probe = options.bootstrapProbe ?? (() => Promise.resolve());
|
|
52
|
+
const resolver = new multi_host_url_resolver_1.MultiHostResolver(options.logger);
|
|
53
|
+
const resolved = await resolver.resolve(options.url, probe);
|
|
54
|
+
return { kind: 'single-host', url: resolved };
|
|
55
|
+
};
|
|
56
|
+
exports.resolveDbConnection = resolveDbConnection;
|
|
57
|
+
//# sourceMappingURL=resolve-db-connection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-db-connection.js","sourceRoot":"","sources":["../../src/multi-host-url/resolve-db-connection.ts"],"names":[],"mappings":";;;AAEA,2CAAsD;AAGtD,uEAA8D;AAE9D,2CAA6D;AAyD7D;;;;;;;;;;;GAWG;AACI,MAAM,mBAAmB,GAAG,KAAK,EACtC,OAAmC,EACJ,EAAE;IACjC,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;IACjD,MAAM,MAAM,GACV,WAAW,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,IAAA,wBAAY,GAAE,CAAC,CAAC,CAAC,IAAI,CAAC;IAElE,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;QAE5C,MAAM,IAAA,4BAAgB,EAAC,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;QACpD,OAAO,CAAC,MAAM,CAAC,IAAI,CACjB,kEAAkE,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,UAAU;YAClG,4FAA4F;YAC5F,wFAAwF;YACxF,uCAAuC,CAC1C,CAAC;QAEF,OAAO;YACL,IAAI,EAAE,cAAc;YACpB,YAAY;YACZ,cAAc,EAAE,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;YACpC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;YAC/D,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;YAC/D,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;YAC/D,MAAM,EAAE,MAAM;SACf,CAAC;IACJ,CAAC;IAED,IAAI,WAAW,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC;QAC5C,IAAI,CAAC,OAAO,CAAC,cAAc,EAAE,CAAC;YAC5B,MAAM,IAAI,mCAAuB,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QAC9D,CAAC;QACD,OAAO,CAAC,MAAM,CAAC,IAAI,CACjB,4FAA4F,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,UAAU;YAC5H,mGAAmG;YACnG,6EAA6E,CAChF,CAAC;IACJ,CAAC;IAED;;;OAGG;IACH,MAAM,KAAK,GACT,OAAO,CAAC,cAAc,IAAI,CAAC,GAAkB,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IACrE,MAAM,QAAQ,GAAG,IAAI,2CAAiB,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACvD,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAE5D,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC;AAChD,CAAC,CAAC;AAlDW,QAAA,mBAAmB,uBAkD9B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('@rsdk/jest/jest.config.unit');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rsdk/db",
|
|
3
|
-
"version": "5.12.0-next.
|
|
3
|
+
"version": "5.12.0-next.9",
|
|
4
4
|
"description": "Common functionality and interfaces for relational database plugins",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"publishConfig": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"@rsdk/common": "*",
|
|
17
17
|
"@rsdk/core": "*",
|
|
18
18
|
"@rsdk/decorators": "*",
|
|
19
|
+
"@rsdk/logging": "*",
|
|
19
20
|
"reflect-metadata": "^0.1.12 || ^0.2.0"
|
|
20
21
|
},
|
|
21
|
-
"gitHead": "
|
|
22
|
+
"gitHead": "43bfe38011bd365e92e35b8d159ab6e47a42c284"
|
|
22
23
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,3 +11,21 @@ export { Propagation } from './propagation.enum';
|
|
|
11
11
|
export { HEALTH_CHECK_QUERY } from './constants';
|
|
12
12
|
export { getSecureContextOptions } from './tls';
|
|
13
13
|
export { SslModeEnum } from './ssl-mode.enum';
|
|
14
|
+
export {
|
|
15
|
+
MultiHostUrl,
|
|
16
|
+
MultiHostUrlParser,
|
|
17
|
+
MultiHostResolver,
|
|
18
|
+
InvalidMultiHostUrl,
|
|
19
|
+
InvalidMultiHostProtocol,
|
|
20
|
+
MultiHostNativeRequired,
|
|
21
|
+
pgProbe,
|
|
22
|
+
resolveDbConnection,
|
|
23
|
+
} from './multi-host-url';
|
|
24
|
+
export type {
|
|
25
|
+
MultiHostEntry,
|
|
26
|
+
MultiHostUrlInit,
|
|
27
|
+
MultiHostProbe,
|
|
28
|
+
PgNativeModule,
|
|
29
|
+
ResolveDbConnectionOptions,
|
|
30
|
+
ResolvedDbConnection,
|
|
31
|
+
} from './multi-host-url';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { BootstrapException } from '@rsdk/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Заменяет `userinfo` (`user:pass@`) в URL-подобной строке на `***:***@`,
|
|
5
|
+
* чтобы пароль из `DB_URL` не попадал в логи через message/details ошибки.
|
|
6
|
+
*/
|
|
7
|
+
const maskUrlCredentials = (str: string): string =>
|
|
8
|
+
str.replace(/(:\/\/)[^/@\s]+@/u, '$1***:***@');
|
|
9
|
+
|
|
10
|
+
export class InvalidMultiHostUrl extends BootstrapException {
|
|
11
|
+
constructor(str: string, cause?: unknown) {
|
|
12
|
+
super(`Invalid multi-host url string: <${maskUrlCredentials(str)}>`, {
|
|
13
|
+
cause,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class InvalidMultiHostProtocol extends BootstrapException {
|
|
19
|
+
constructor(expectedProtocols: string[], protocol: string, url: string) {
|
|
20
|
+
super(`Invalid protocol: <${protocol}>, expected: ${expectedProtocols}`, {
|
|
21
|
+
details: {
|
|
22
|
+
protocol,
|
|
23
|
+
url: maskUrlCredentials(url),
|
|
24
|
+
expected: expectedProtocols,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Multi-host URL задан, но `pg-native` не установлен — runtime failover
|
|
32
|
+
* через libpq невозможен. По умолчанию `resolveDbConnection` падает на
|
|
33
|
+
* старте, чтобы single-host pool не выдавался под обещание failover,
|
|
34
|
+
* которого не будет. Чтобы разрешить тихий фолбэк на bootstrap-resolve,
|
|
35
|
+
* передайте в `resolveDbConnection` поле `bootstrapProbe` — в плагинах
|
|
36
|
+
* `@rsdk/db.*` это управляется env-переменной `DB_MULTIHOST_BOOTSTRAP_FALLBACK`.
|
|
37
|
+
*/
|
|
38
|
+
export class MultiHostNativeRequired extends BootstrapException {
|
|
39
|
+
constructor(hostsCount: number) {
|
|
40
|
+
super(
|
|
41
|
+
`multi-host db: \`pg-native\` is not installed but URL has ${hostsCount} hosts. ` +
|
|
42
|
+
'Install `pg-native` (and a system libpq) for libpq runtime failover, ' +
|
|
43
|
+
'or pass `bootstrapProbe` to resolveDbConnection to allow falling back ' +
|
|
44
|
+
'to bootstrap-resolve (no runtime failover). In @rsdk/db.* plugins this ' +
|
|
45
|
+
'is controlled by DB_MULTIHOST_BOOTSTRAP_FALLBACK=true.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { MultiHostUrl } from './multi-host-url';
|
|
2
|
+
export type { MultiHostEntry, MultiHostUrlInit } from './multi-host-url';
|
|
3
|
+
export { MultiHostUrlParser } from './multi-host-url.parser';
|
|
4
|
+
export { MultiHostResolver } from './multi-host-url.resolver';
|
|
5
|
+
export type { MultiHostProbe } from './multi-host-url.resolver';
|
|
6
|
+
export {
|
|
7
|
+
InvalidMultiHostUrl,
|
|
8
|
+
InvalidMultiHostProtocol,
|
|
9
|
+
MultiHostNativeRequired,
|
|
10
|
+
} from './exception';
|
|
11
|
+
export { pgProbe } from './pg-native';
|
|
12
|
+
export type { PgNativeModule } from './pg-native';
|
|
13
|
+
export { resolveDbConnection } from './resolve-db-connection';
|
|
14
|
+
export type {
|
|
15
|
+
ResolveDbConnectionOptions,
|
|
16
|
+
ResolvedDbConnection,
|
|
17
|
+
} from './resolve-db-connection';
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { InvalidMultiHostProtocol, InvalidMultiHostUrl } from './exception';
|
|
2
|
+
import { MultiHostUrl } from './multi-host-url';
|
|
3
|
+
import { MultiHostUrlParser } from './multi-host-url.parser';
|
|
4
|
+
|
|
5
|
+
describe('MultiHostUrlParser', () => {
|
|
6
|
+
const parser = new MultiHostUrlParser();
|
|
7
|
+
|
|
8
|
+
describe('single-host URL (backward compat)', () => {
|
|
9
|
+
it('parses postgres://user:pass@host:5432/db', () => {
|
|
10
|
+
const url = parser.parse('postgres://app:secret@db.local:5432/myapp');
|
|
11
|
+
|
|
12
|
+
expect(url).toBeInstanceOf(MultiHostUrl);
|
|
13
|
+
expect(url.protocol).toBe('postgres:');
|
|
14
|
+
expect(url.username).toBe('app');
|
|
15
|
+
expect(url.password).toBe('secret');
|
|
16
|
+
expect(url.hosts).toEqual([{ host: 'db.local', port: 5432 }]);
|
|
17
|
+
expect(url.database).toBe('myapp');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('omits port from MultiHostEntry when not in URL', () => {
|
|
21
|
+
const url = parser.parse('postgres://u:p@db.local/myapp');
|
|
22
|
+
|
|
23
|
+
expect(url.hosts).toEqual([{ host: 'db.local' }]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('multi-host URL', () => {
|
|
28
|
+
it('parses two hosts with explicit ports', () => {
|
|
29
|
+
const url = parser.parse('postgres://app:secret@h1:5432,h2:5433/myapp');
|
|
30
|
+
|
|
31
|
+
expect(url.hosts).toEqual([
|
|
32
|
+
{ host: 'h1', port: 5432 },
|
|
33
|
+
{ host: 'h2', port: 5433 },
|
|
34
|
+
]);
|
|
35
|
+
expect(url.database).toBe('myapp');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('omits port for hosts that do not specify it (mixed)', () => {
|
|
39
|
+
const url = parser.parse('postgres://u:p@h1,h2:5433,h3/myapp');
|
|
40
|
+
|
|
41
|
+
expect(url.hosts).toEqual([
|
|
42
|
+
{ host: 'h1' },
|
|
43
|
+
{ host: 'h2', port: 5433 },
|
|
44
|
+
{ host: 'h3' },
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('preserves searchParams', () => {
|
|
49
|
+
const url = parser.parse(
|
|
50
|
+
'postgres://u:p@h1:5432,h2:5432/db?sslmode=require&application_name=foo',
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(url.searchParams.get('sslmode')).toBe('require');
|
|
54
|
+
expect(url.searchParams.get('application_name')).toBe('foo');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('singleHostUrl produces a valid single-host URL', () => {
|
|
58
|
+
const url = parser.parse(
|
|
59
|
+
'postgres://app:secret@h1:5432,h2:5433/myapp?sslmode=require',
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const single0 = url.getHostAt(0);
|
|
63
|
+
|
|
64
|
+
expect(single0.hostname).toBe('h1');
|
|
65
|
+
expect(single0.port).toBe('5432');
|
|
66
|
+
expect(single0.username).toBe('app');
|
|
67
|
+
expect(single0.password).toBe('secret');
|
|
68
|
+
expect(single0.pathname).toBe('/myapp');
|
|
69
|
+
expect(single0.searchParams.get('sslmode')).toBe('require');
|
|
70
|
+
|
|
71
|
+
const single1 = url.getHostAt(1);
|
|
72
|
+
|
|
73
|
+
expect(single1.hostname).toBe('h2');
|
|
74
|
+
expect(single1.port).toBe('5433');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('toString returns multi-host form', () => {
|
|
78
|
+
const url = parser.parse('postgres://u:p@h1:5432,h2:5433/db');
|
|
79
|
+
|
|
80
|
+
expect(url.toString()).toBe('postgres://u:p@h1:5432,h2:5433/db');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('toString preserves searchParams', () => {
|
|
84
|
+
const url = parser.parse(
|
|
85
|
+
'postgres://u:p@h1:5432,h2:5433/db?sslmode=require',
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(url.toString()).toBe(
|
|
89
|
+
'postgres://u:p@h1:5432,h2:5433/db?sslmode=require',
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('protocol validation', () => {
|
|
95
|
+
it('accepts URL when protocol matches one of allowed', () => {
|
|
96
|
+
const restricted = new MultiHostUrlParser('postgres:', 'postgresql:');
|
|
97
|
+
|
|
98
|
+
expect(() =>
|
|
99
|
+
restricted.parse('postgres://u:p@h1:5432,h2:5432/db'),
|
|
100
|
+
).not.toThrow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects URL when protocol is not in allowed list', () => {
|
|
104
|
+
const restricted = new MultiHostUrlParser('postgres:');
|
|
105
|
+
|
|
106
|
+
expect(() => restricted.parse('mysql://u:p@h1:5432,h2:5432/db')).toThrow(
|
|
107
|
+
InvalidMultiHostProtocol,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('invalid input', () => {
|
|
113
|
+
it.each([
|
|
114
|
+
['empty hosts', 'postgres://u:p@/db'],
|
|
115
|
+
['empty entry between commas', 'postgres://u:p@h1,,h2/db'],
|
|
116
|
+
['port out of range', 'postgres://u:p@h1:99999/db'],
|
|
117
|
+
['non-numeric port', 'postgres://u:p@h1:abc/db'],
|
|
118
|
+
['missing scheme', 'h1:5432,h2:5432/db'],
|
|
119
|
+
['non-string input (number)', 42],
|
|
120
|
+
])('rejects %s', (_, input) => {
|
|
121
|
+
expect(() => parser.parse(input)).toThrow(InvalidMultiHostUrl);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('parser metadata', () => {
|
|
126
|
+
it('exposes type and description', () => {
|
|
127
|
+
expect(parser.type()).toBe('MultiHostUrl');
|
|
128
|
+
expect(parser.description()).toContain('MultiHostUrl');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Assert } from '@rsdk/common';
|
|
2
|
+
import type { PropertyParser } from '@rsdk/core';
|
|
3
|
+
|
|
4
|
+
import { InvalidMultiHostProtocol, InvalidMultiHostUrl } from './exception';
|
|
5
|
+
import type { MultiHostEntry } from './multi-host-url';
|
|
6
|
+
import { MultiHostUrl } from './multi-host-url';
|
|
7
|
+
|
|
8
|
+
const PLACEHOLDER_HOST = '__rsdk_multihost_placeholder__';
|
|
9
|
+
|
|
10
|
+
export class MultiHostUrlParser implements PropertyParser<MultiHostUrl> {
|
|
11
|
+
private readonly protocols: string[];
|
|
12
|
+
|
|
13
|
+
constructor(...protocols: string[]) {
|
|
14
|
+
this.protocols = protocols;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type(): string {
|
|
18
|
+
return 'MultiHostUrl';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
description(): string {
|
|
22
|
+
return 'Parses string into MultiHostUrl supporting <scheme>://user:pass@host1:port1,host2:port2/db?... syntax';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
parse(value: unknown): MultiHostUrl {
|
|
26
|
+
try {
|
|
27
|
+
Assert.isString(value);
|
|
28
|
+
|
|
29
|
+
const splitted = this.getSplittedOnHosts(value);
|
|
30
|
+
const placeholder = `${splitted.beforeHosts}${PLACEHOLDER_HOST}${splitted.afterHosts}`;
|
|
31
|
+
const url = new URL(placeholder);
|
|
32
|
+
|
|
33
|
+
this.assertProtocol(url.protocol, value);
|
|
34
|
+
|
|
35
|
+
const hosts = this.parseHosts(splitted.hostsSegment, value);
|
|
36
|
+
const database = this.extractDatabase(url);
|
|
37
|
+
|
|
38
|
+
return new MultiHostUrl({
|
|
39
|
+
protocol: url.protocol,
|
|
40
|
+
...(url.username && {
|
|
41
|
+
username: decodeURIComponent(url.username),
|
|
42
|
+
}),
|
|
43
|
+
...(url.password && {
|
|
44
|
+
password: decodeURIComponent(url.password),
|
|
45
|
+
}),
|
|
46
|
+
hosts,
|
|
47
|
+
...(database && { database }),
|
|
48
|
+
searchParams: url.searchParams,
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (
|
|
52
|
+
error instanceof InvalidMultiHostUrl ||
|
|
53
|
+
error instanceof InvalidMultiHostProtocol
|
|
54
|
+
) {
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
throw new InvalidMultiHostUrl(String(value), error);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Разбивает строку на три части: то, что слева от списка хостов
|
|
63
|
+
* (`scheme://[userinfo@]`), сам список (`h1:5432,h2:5432,...`) и то,
|
|
64
|
+
* что справа (`/db?params`). Делается вручную, потому что multi-host
|
|
65
|
+
* URI ломает `new URL()` ещё до того, как мы дойдём до парсинга хостов.
|
|
66
|
+
* Дальше вызывающая сторона подставляет в `hostsSegment` placeholder и
|
|
67
|
+
* скармливает результат `new URL()` уже как валидный single-host URI.
|
|
68
|
+
*/
|
|
69
|
+
private getSplittedOnHosts(value: string): {
|
|
70
|
+
beforeHosts: string;
|
|
71
|
+
hostsSegment: string;
|
|
72
|
+
afterHosts: string;
|
|
73
|
+
} {
|
|
74
|
+
// scheme заканчивается на `://`. Без него это не URI.
|
|
75
|
+
const schemeEnd = value.indexOf('://');
|
|
76
|
+
if (schemeEnd === -1) {
|
|
77
|
+
throw new InvalidMultiHostUrl(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Authority — всё между `://` и следующим `/`, `?` или `#`.
|
|
81
|
+
// Внутри authority может быть `userinfo@hosts`.
|
|
82
|
+
const authorityStart = schemeEnd + 3;
|
|
83
|
+
const pathStart = this.findPathStart(value, authorityStart);
|
|
84
|
+
const authority = value.slice(authorityStart, pathStart);
|
|
85
|
+
const after = value.slice(pathStart);
|
|
86
|
+
|
|
87
|
+
// `userinfo` отделяется от hosts последним `@`. `lastIndexOf` —
|
|
88
|
+
// на случай, если в пароле встретится сам символ `@` (хотя по
|
|
89
|
+
// спеке его положено percent-кодировать, на практике встречается).
|
|
90
|
+
const atIdx = authority.lastIndexOf('@');
|
|
91
|
+
const userInfo = atIdx === -1 ? '' : authority.slice(0, atIdx + 1);
|
|
92
|
+
const hostsSegment = atIdx === -1 ? authority : authority.slice(atIdx + 1);
|
|
93
|
+
|
|
94
|
+
if (!hostsSegment) {
|
|
95
|
+
throw new InvalidMultiHostUrl(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// `beforeHosts` сохраняем вместе с userInfo — placeholder будет
|
|
99
|
+
// подставлен только вместо hosts, чтобы `new URL()` корректно
|
|
100
|
+
// распарсил scheme/userinfo/path/query.
|
|
101
|
+
return {
|
|
102
|
+
beforeHosts: value.slice(0, authorityStart) + userInfo,
|
|
103
|
+
hostsSegment,
|
|
104
|
+
afterHosts: after,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Возвращает имя БД из pathname (`/db` → `db`). Пустой pathname (URL без
|
|
110
|
+
* пути) даёт `''` — вызывающая сторона трактует это как «database не задан».
|
|
111
|
+
*/
|
|
112
|
+
private extractDatabase(url: URL): string {
|
|
113
|
+
return url.pathname.replace(/^\//, '');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private findPathStart(value: string, from: number): number {
|
|
117
|
+
for (let i = from; i < value.length; i++) {
|
|
118
|
+
const ch = value[i];
|
|
119
|
+
if (ch === '/' || ch === '?' || ch === '#') {
|
|
120
|
+
return i;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return value.length;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private parseHosts(segment: string, original: string): MultiHostEntry[] {
|
|
127
|
+
const parts = segment.split(',');
|
|
128
|
+
const hosts: MultiHostEntry[] = [];
|
|
129
|
+
|
|
130
|
+
for (const part of parts) {
|
|
131
|
+
const trimmed = part.trim();
|
|
132
|
+
if (!trimmed) {
|
|
133
|
+
throw new InvalidMultiHostUrl(original);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const colonIdx = trimmed.lastIndexOf(':');
|
|
137
|
+
let host: string;
|
|
138
|
+
let port: number | undefined;
|
|
139
|
+
|
|
140
|
+
if (colonIdx === -1) {
|
|
141
|
+
// Порт отсутствует — оставляем undefined; default подставит вызывающая сторона.
|
|
142
|
+
host = trimmed;
|
|
143
|
+
port = undefined;
|
|
144
|
+
} else {
|
|
145
|
+
host = trimmed.slice(0, colonIdx);
|
|
146
|
+
const portStr = trimmed.slice(colonIdx + 1);
|
|
147
|
+
const parsed = Number(portStr);
|
|
148
|
+
if (
|
|
149
|
+
portStr.length === 0 ||
|
|
150
|
+
!Number.isInteger(parsed) ||
|
|
151
|
+
parsed < 1 ||
|
|
152
|
+
parsed > 65535
|
|
153
|
+
) {
|
|
154
|
+
throw new InvalidMultiHostUrl(original);
|
|
155
|
+
}
|
|
156
|
+
port = parsed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!host) {
|
|
160
|
+
throw new InvalidMultiHostUrl(original);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
hosts.push(port === undefined ? { host } : { host, port });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (hosts.length === 0) {
|
|
167
|
+
throw new InvalidMultiHostUrl(original);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return hosts;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private assertProtocol(protocol: string, original: string): void {
|
|
174
|
+
if (this.protocols.length === 0) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!this.protocols.includes(protocol)) {
|
|
179
|
+
throw new InvalidMultiHostProtocol(this.protocols, protocol, original);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|