@eliware/elera-lib 0.1.9 → 0.1.10
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/README.md +14 -5
- package/RELEASE_NOTES.md +22 -0
- package/package.json +1 -1
- package/src/client/authorization-context.mjs +9 -0
- package/src/client/create-db.mjs +5 -3
- package/src/index.d.ts +7 -4
- package/src/index.mjs +1 -0
- package/src/routing/stream-client.mjs +3 -3
- package/src/telemetry.mjs +2 -2
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The alternative SQL client for Eliware applications. It provides generic
|
|
4
4
|
primary/balanced MySQL or MariaDB routing without embedding Elera, HAProxy,
|
|
5
|
-
backup, or GitOps policy. It is a v0.1.
|
|
5
|
+
backup, or GitOps policy. It is a v0.1.10 alternative to `@eliware/mysql`; the
|
|
6
6
|
existing package is intentionally unchanged. The current package version is
|
|
7
|
-
0.1.
|
|
7
|
+
0.1.10.
|
|
8
8
|
|
|
9
9
|
`primary` is the preferred connection path. `balanced` is an optional alternate
|
|
10
10
|
path. Both may accept writes; automatic routing sends only conservative,
|
|
@@ -34,7 +34,8 @@ Routing bundles passed to `createDbFromBundle` use the normalized shape
|
|
|
34
34
|
`routes.primary` and `routes.balanced`, each containing ordered `{ host, port,
|
|
35
35
|
weight }` nodes. A bundle may also carry an explicit `writer`, ordered
|
|
36
36
|
`failover`, and `readers` assignment. The bundle carries `database`,
|
|
37
|
-
`identity`, optional `
|
|
37
|
+
`identity`, optional `application`, `credentialName`, `scopes`, and
|
|
38
|
+
`credentials`, and `expiresAt`; `validateBundle` rejects
|
|
38
39
|
expired, malformed, duplicated, or conflicting route data. The checked-in
|
|
39
40
|
contract fixture documents the supervisor-facing wire representation
|
|
40
41
|
separately.
|
|
@@ -49,6 +50,12 @@ library does not know about supervisors, Elera, HAProxy, GitOps, backups, or
|
|
|
49
50
|
CLI commands. Applications provide those integrations through ordinary
|
|
50
51
|
configuration and callbacks.
|
|
51
52
|
|
|
53
|
+
An application-scoped token should resolve to one application, database, and
|
|
54
|
+
credential context. The library does not select databases or credentials from
|
|
55
|
+
request arguments. Callers that already have that authorization context may
|
|
56
|
+
pass `tokenContext` to `createDb`; bundle creation and refresh then reject
|
|
57
|
+
cross-database, identity, credential, or scope mismatches.
|
|
58
|
+
|
|
52
59
|
When a route node is drained, the client immediately stops assigning new work
|
|
53
60
|
to that node while existing operations continue. The drain window defaults to
|
|
54
61
|
45 seconds and is capped at 45 seconds; remaining pool connections are then
|
|
@@ -100,8 +107,10 @@ Applications may opt into generic in-memory telemetry with
|
|
|
100
107
|
failures, retries, in-flight work, and latency are exposed through
|
|
101
108
|
`client.telemetry` and sent over an attached routing stream once per second.
|
|
102
109
|
Telemetry is observational only; it does not carry SQL or credentials.
|
|
103
|
-
|
|
104
|
-
the
|
|
110
|
+
The snapshot may include the application, database, credential name, and
|
|
111
|
+
scopes associated with the already-authorized client, plus reconnect, failover,
|
|
112
|
+
and cumulative reconnect-delay counters. It never includes bearer tokens or
|
|
113
|
+
passwords.
|
|
105
114
|
|
|
106
115
|
`createMaterializer` supports bounded plaintext use for a caller-provided
|
|
107
116
|
operation. It creates a mode-restricted temporary file and removes its entire
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Release notes
|
|
2
2
|
|
|
3
|
+
## 0.1.10 — Token-bound routing context
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Adds `validateTokenContext` for enforcing an application token's authorized
|
|
8
|
+
application, database, credential, identity, and scopes against a routing
|
|
9
|
+
bundle.
|
|
10
|
+
- Applies that authorization check during initial client creation and every
|
|
11
|
+
subsequent bundle refresh.
|
|
12
|
+
- Keeps routing-stream authorization token-only; database and application
|
|
13
|
+
selectors are not sent as query parameters.
|
|
14
|
+
- Includes safe application, database, credential, and scope context in
|
|
15
|
+
opt-in in-memory telemetry without exposing tokens or passwords.
|
|
16
|
+
|
|
17
|
+
### Validation
|
|
18
|
+
|
|
19
|
+
- Adds focused coverage for matching contexts, rejected cross-database and
|
|
20
|
+
scope mismatches, refresh-time enforcement, and isolated token contexts.
|
|
21
|
+
- Maintains 100% statements, branches, functions, and lines coverage with zero
|
|
22
|
+
lint warnings.
|
|
23
|
+
- Typecheck, contract, syntax, and package dry-run checks pass.
|
|
24
|
+
|
|
3
25
|
## 0.1.9 — Standalone outage classification and complete operation telemetry
|
|
4
26
|
|
|
5
27
|
### Added
|
package/package.json
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function validateTokenContext(bundle, tokenContext = {}) {
|
|
2
|
+
if (!bundle || typeof bundle !== 'object') throw new TypeError('routing bundle is required');
|
|
3
|
+
for (const field of ['application', 'database', 'credentialName']) {
|
|
4
|
+
if (tokenContext[field] !== undefined && bundle[field] !== tokenContext[field]) throw new Error(`routing bundle ${field} does not match token context`);
|
|
5
|
+
}
|
|
6
|
+
if (tokenContext.identity !== undefined && bundle.identity !== tokenContext.identity) throw new Error('routing bundle identity does not match token context');
|
|
7
|
+
if (tokenContext.scopes !== undefined && (!Array.isArray(bundle.scopes) || tokenContext.scopes.some((scope) => !bundle.scopes.includes(scope)))) throw new Error('routing bundle scopes exceed token context');
|
|
8
|
+
return bundle;
|
|
9
|
+
}
|
package/src/client/create-db.mjs
CHANGED
|
@@ -3,17 +3,18 @@ import * as mysql from 'mysql2/promise';
|
|
|
3
3
|
import { validateProfile, redactedProfile } from '../config.mjs';
|
|
4
4
|
import { asSqlError } from '../errors.mjs';
|
|
5
5
|
import { resolveCredentials, credentialContext } from '../credential-provider.mjs';
|
|
6
|
-
import { validateBundle, bundleExpired, bundleNeedsRefresh } from '../bundle.mjs';
|
|
6
|
+
import { validateBundle as validateBundleShape, bundleExpired, bundleNeedsRefresh } from '../bundle.mjs';
|
|
7
7
|
import { createRouteFactory } from './route-factory.mjs';
|
|
8
8
|
import { classifyQuery, routeFor } from '../routing.mjs';
|
|
9
9
|
import { compareBundleVersions } from '../routing/bundle-version.mjs';
|
|
10
10
|
import { clientDrainTimeout } from '../lifecycle/drain-policy.mjs';
|
|
11
11
|
import { createTelemetry } from '../telemetry.mjs';
|
|
12
12
|
import { createTimedOperation } from './telemetry-wrapper.mjs';
|
|
13
|
+
import { validateTokenContext } from './authorization-context.mjs';
|
|
13
14
|
|
|
14
15
|
const olderVersion = (candidate, current) => compareBundleVersions(candidate, current) < 0;
|
|
15
16
|
|
|
16
|
-
export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, quarantineMs = 5000, drainTimeoutMs = 45000, now = () => Date.now(), telemetry } = {}) {
|
|
17
|
+
export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, tokenContext, quarantineMs = 5000, drainTimeoutMs = 45000, now = () => Date.now(), telemetry } = {}) {
|
|
17
18
|
if (!primary || typeof primary !== 'object') throw new TypeError('primary connection profile is required');
|
|
18
19
|
const credentials = await resolveCredentials(credentialProvider, credentialContext(primary, { identity }));
|
|
19
20
|
let primaryConfig = validateProfile({ ...primary, ...credentials }, 'primary');
|
|
@@ -23,12 +24,13 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
|
|
|
23
24
|
primaryConfig = validateProfile({ ...primaryConfig, ...credentials }, 'primary');
|
|
24
25
|
if (balancedConfig) balancedConfig = validateProfile({ ...balancedConfig, ...credentials }, 'balanced');
|
|
25
26
|
}
|
|
27
|
+
const validateBundle = (candidate) => validateTokenContext(validateBundleShape(candidate), tokenContext);
|
|
26
28
|
let activeBundle = bundle ? validateBundle(bundle) : undefined;
|
|
27
29
|
const makeRoute = (route, fallback) => createRouteFactory({ bundle: activeBundle, now, mysqlLib, log, quarantineMs })(route, fallback);
|
|
28
30
|
let primaryPool = makeRoute('primary', primaryConfig);
|
|
29
31
|
let balancedPool = balancedConfig || activeBundle?.routes?.balanced ? makeRoute('balanced', balancedConfig ?? primaryConfig) : null;
|
|
30
32
|
const choose = (sql, options = {}) => options.connection ?? (routeFor(sql, options.route ?? routing) === 'balanced' && balancedPool ? balancedPool : primaryPool);
|
|
31
|
-
const metrics = telemetry === true ? createTelemetry({ application: bundle?.application ??
|
|
33
|
+
const metrics = telemetry === true ? createTelemetry({ application: bundle?.application ?? 'default', credentialName: bundle?.credentialName, database: bundle?.database, scopes: bundle?.scopes, now }) : telemetry;
|
|
32
34
|
const timed = createTimedOperation({ metrics, now });
|
|
33
35
|
const client = {
|
|
34
36
|
async query(sql, values, options) { return timed(async () => { const selected = choose(sql, options); try { return await selected.query(sql, values); } catch (error) { const requestedRoute = options?.route ?? routing; if (error.retryable && balancedPool && routeFor(sql, requestedRoute) === 'balanced' && classifyQuery(sql) === 'balanced') { metrics?.record?.({ retry: true }); return balancedPool.query(sql, values); } throw error; } }); },
|
package/src/index.d.ts
CHANGED
|
@@ -15,6 +15,9 @@ export interface RoutingBundle {
|
|
|
15
15
|
apiVersion?: string;
|
|
16
16
|
database?: string;
|
|
17
17
|
identity?: string;
|
|
18
|
+
application?: string;
|
|
19
|
+
credentialName?: string;
|
|
20
|
+
scopes?: string[];
|
|
18
21
|
credentials?: { username?: string; password?: string };
|
|
19
22
|
bundleVersion?: number | string;
|
|
20
23
|
expiresAt: string;
|
|
@@ -28,7 +31,7 @@ export interface CredentialProviderResult { user: string; password: string; }
|
|
|
28
31
|
export type CredentialProvider = (context: { database: string; identity: string | null; route: string }) => Promise<CredentialProviderResult> | CredentialProviderResult;
|
|
29
32
|
export type QueryFunction = (sql: string, values?: unknown) => Promise<any>;
|
|
30
33
|
export interface DbOptions { route?: 'auto' | 'primary' | 'balanced'; connection?: unknown; }
|
|
31
|
-
export interface TelemetrySnapshot { type: 'client.telemetry'; application: string; queries: number; failures: number; retries: number; reconnects: number; failoverCount: number; reconnectDelayMs: number; inflight: number; totalLatencyMs: number; maxLatencyMs: number; avgLatencyMs: number; sentAt: string; }
|
|
34
|
+
export interface TelemetrySnapshot { type: 'client.telemetry'; application: string; credentialName?: string; database?: string; scopes?: string[]; queries: number; failures: number; retries: number; reconnects: number; failoverCount: number; reconnectDelayMs: number; inflight: number; totalLatencyMs: number; maxLatencyMs: number; avgLatencyMs: number; sentAt: string; }
|
|
32
35
|
export interface Telemetry { begin(): number; record(event?: { latencyMs?: number; failed?: boolean; retry?: boolean; reconnect?: boolean; failover?: boolean }): void; recordReconnect(event?: { delayMs?: number; failover?: boolean }): void; snapshot(): TelemetrySnapshot; start(stream: Pick<RoutingStream, 'sendTelemetry'>): void; stop(): void; }
|
|
33
36
|
|
|
34
37
|
export interface DbClient {
|
|
@@ -61,7 +64,7 @@ export interface RoutingStream {
|
|
|
61
64
|
export type RoutingEvent = { type: 'routing.update' | 'routing.resync' | 'routing.drain' | 'routing.recovery'; node?: string; version?: number | string; [key: string]: unknown } | { type: 'routing.shutdown'; node?: string; reason?: string; reconnect?: boolean; reconnectDeadlineMs?: number; loadBalancerEndpoint?: string };
|
|
62
65
|
export function validateRoutingEvent(event: unknown): RoutingEvent;
|
|
63
66
|
|
|
64
|
-
export function createDb(options: { primary: ConnectionProfile; balanced?: Partial<ConnectionProfile>; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; drainTimeoutMs?: number; now?: () => number; telemetry?: true | Telemetry }): Promise<DbClient>;
|
|
67
|
+
export function createDb(options: { primary: ConnectionProfile; balanced?: Partial<ConnectionProfile>; bundle?: RoutingBundle; tokenContext?: { application?: string; database?: string; credentialName?: string; identity?: string; scopes?: string[] }; credentialProvider?: CredentialProvider; identity?: string; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; drainTimeoutMs?: number; now?: () => number; telemetry?: true | Telemetry }): Promise<DbClient>;
|
|
65
68
|
export function createDbFromEnvironment(options?: { env?: Record<string, string | undefined>; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; telemetry?: true | Telemetry }): Promise<DbClient>;
|
|
66
69
|
export function classifyQuery(sql: unknown): 'primary' | 'balanced';
|
|
67
70
|
export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balanced'): 'primary' | 'balanced';
|
|
@@ -78,7 +81,7 @@ export function bundleNeedsRefresh(bundle: RoutingBundle, now?: number): boolean
|
|
|
78
81
|
export function createAdminSql(options: { query: QueryFunction }): { transaction<T>(work: (context: { query: QueryFunction }) => Promise<T>): Promise<T>; migration(statements?: string[]): Promise<unknown> };
|
|
79
82
|
export function createMigrationRunner(options: { query: QueryFunction; migrations?: Array<{ version: number; name: string; statements: string[] }> }): { status(): Promise<{ applied: unknown[] }>; migrate(): Promise<unknown> };
|
|
80
83
|
export function selectRouteNodes(options: { bundle: RoutingBundle; route?: 'primary' | 'balanced'; now?: number }): RoutingNode[];
|
|
81
|
-
export function createRoutingStream(options: { endpoint: string; token?: string;
|
|
84
|
+
export function createRoutingStream(options: { endpoint: string; token?: string; fetchBundle: () => Promise<RoutingBundle>; onUpdate?: (event: unknown) => void; onError?: (error: unknown) => void; reconnectMs?: number; maxReconnectMs?: number; heartbeatMs?: number; telemetry?: Pick<Telemetry, 'recordReconnect'> }): RoutingStream;
|
|
82
85
|
export function writerAssignment(bundle: RoutingBundle): WriterAssignment;
|
|
83
86
|
export function failoverNodes(bundle: RoutingBundle): WriterAssignment[];
|
|
84
87
|
export function compareBundleVersions(left: number | string | undefined, right: number | string | undefined): number;
|
|
@@ -87,4 +90,4 @@ export function clientDrainTimeout(timeoutMs?: number): number;
|
|
|
87
90
|
export function createQuiesceController(options?: { close?: () => Promise<void>; onChange?: (state: string) => void }): { state(): { state: string; active: number }; enter(): () => void; begin(): Promise<void>; end(): Promise<void>; close(): Promise<void> };
|
|
88
91
|
export function createSqlVerifier(options: { query: QueryFunction }): { connectivity(): Promise<{ verified: boolean }>; schema(database: string): Promise<{ database: string; verified: boolean }>; account(user: string, host?: string): Promise<{ user: string; host: string; verified: boolean; grants: string[] }>; all(options?: { database?: string; user?: string; host?: string }): Promise<unknown> };
|
|
89
92
|
export function createMaterializer(options?: Record<string, unknown>): unknown;
|
|
90
|
-
export function createTelemetry(options?: { application?: string; intervalMs?: number; now?: () => number; setIntervalImpl?: typeof setInterval; clearIntervalImpl?: typeof clearInterval }): Telemetry;
|
|
93
|
+
export function createTelemetry(options?: { application?: string; credentialName?: string; database?: string; scopes?: string[]; intervalMs?: number; now?: () => number; setIntervalImpl?: typeof setInterval; clearIntervalImpl?: typeof clearInterval }): Telemetry;
|
package/src/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { createDb } from './client/create-db.mjs';
|
|
2
|
+
export { validateTokenContext } from './client/authorization-context.mjs';
|
|
2
3
|
export { createDbFromBundle, profilesFromBundle } from './client/from-bundle.mjs';
|
|
3
4
|
export { createDbFromEnvironment } from './client/environment.mjs';
|
|
4
5
|
export { classifyQuery, routeFor } from './routing.mjs';
|
|
@@ -2,12 +2,12 @@ import { log as defaultLog } from '@eliware/common';
|
|
|
2
2
|
import { compareBundleVersions } from './bundle-version.mjs';
|
|
3
3
|
import { validateRoutingEvent } from './event-contract.mjs';
|
|
4
4
|
|
|
5
|
-
export function createRoutingStream({ endpoint, token,
|
|
5
|
+
export function createRoutingStream({ endpoint, token, fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, heartbeatMs = 45000, now = () => Date.now(), telemetry } = {}) {
|
|
6
6
|
if (!endpoint || typeof fetchBundle !== 'function') throw new TypeError('endpoint and fetchBundle are required');
|
|
7
7
|
let socket; let closed = false; let timer; let heartbeat; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected'; let plannedReconnect = false; let lastReconnectWasPlanned = false; let disconnectedAt; let reconnectDeadlineAt;
|
|
8
8
|
const log = arguments[0]?.log ?? defaultLog;
|
|
9
|
-
const streamUrl = () => `${endpoint.replace(/^http/i, 'ws').replace(/\/$/, '')}/api/v1/routing/stream?
|
|
10
|
-
async function fallback() { try { const bundle = await fetchBundle(
|
|
9
|
+
const streamUrl = () => `${endpoint.replace(/^http/i, 'ws').replace(/\/$/, '')}/api/v1/routing/stream?token=${encodeURIComponent(token ?? '')}`;
|
|
10
|
+
async function fallback() { try { const bundle = await fetchBundle(); if (closed) return; mode = 'rest'; updateHandler?.({ type: 'routing.resync', version: expectedVersion, bundle, receivedAt: now() }); } catch (error) { if (closed) return; mode = 'disconnected'; onError?.(error); log.warn?.('Routing REST fallback failed', { error }); } }
|
|
11
11
|
function schedule() { if (closed || timer || (reconnectDeadlineAt !== undefined && now() >= reconnectDeadlineAt)) return; const wait = Math.min(delay, Math.max(0, reconnectDeadlineAt === undefined ? delay : reconnectDeadlineAt - now())); timer = setTimeout(() => { timer = undefined; void connect(); }, wait); delay = Math.min(maxReconnectMs, delay * 2); }
|
|
12
12
|
async function connect() {
|
|
13
13
|
if (closed || typeof WebSocketImpl !== 'function') { await fallback(); schedule(); return; }
|
package/src/telemetry.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export function createTelemetry({ application = 'default', intervalMs = 1000, now = () => Date.now(), setIntervalImpl = setInterval, clearIntervalImpl = clearInterval } = {}) {
|
|
1
|
+
export function createTelemetry({ application = 'default', credentialName = undefined, database = undefined, scopes = undefined, intervalMs = 1000, now = () => Date.now(), setIntervalImpl = setInterval, clearIntervalImpl = clearInterval } = {}) {
|
|
2
2
|
const stats = { queries: 0, failures: 0, retries: 0, reconnects: 0, failoverCount: 0, reconnectDelayMs: 0, inflight: 0, totalLatencyMs: 0, maxLatencyMs: 0 };
|
|
3
3
|
let timer;
|
|
4
4
|
const begin = () => { stats.inflight += 1; return now(); };
|
|
5
5
|
const record = ({ latencyMs = 0, failed = false, retry = false, reconnect = false, failover = false } = {}) => { stats.queries += 1; stats.inflight = Math.max(0, stats.inflight - 1); stats.totalLatencyMs += latencyMs; stats.maxLatencyMs = Math.max(stats.maxLatencyMs, latencyMs); if (failed) stats.failures += 1; if (retry) stats.retries += 1; if (reconnect) stats.reconnects += 1; if (failover) stats.failoverCount += 1; };
|
|
6
|
-
const snapshot = () => ({ type: 'client.telemetry', application, ...stats, avgLatencyMs: stats.queries ? stats.totalLatencyMs / stats.queries : 0, sentAt: new Date(now()).toISOString() });
|
|
6
|
+
const snapshot = () => ({ type: 'client.telemetry', application, credentialName, database, scopes, ...stats, avgLatencyMs: stats.queries ? stats.totalLatencyMs / stats.queries : 0, sentAt: new Date(now()).toISOString() });
|
|
7
7
|
const recordReconnect = ({ delayMs = 0, failover = false } = {}) => { stats.reconnects += 1; stats.reconnectDelayMs += Math.max(0, Number(delayMs) || 0); if (failover) stats.failoverCount += 1; };
|
|
8
8
|
return { begin, record, recordReconnect, snapshot, start(stream) { if (timer) return; timer = setIntervalImpl(() => stream.sendTelemetry?.(snapshot()), intervalMs); timer.unref?.(); }, stop() { if (timer) clearIntervalImpl(timer); timer = undefined; } };
|
|
9
9
|
}
|