@eliware/elera-lib 0.1.5 → 0.1.6
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 +13 -3
- package/RELEASE_NOTES.md +21 -0
- package/package.json +1 -1
- package/src/client/create-db.mjs +1 -1
- package/src/index.d.ts +4 -3
- package/src/routing/stream-client.mjs +15 -6
- package/src/telemetry.mjs +3 -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.6 alternative to `@eliware/mysql`; the
|
|
6
6
|
existing package is intentionally unchanged. The current package version is
|
|
7
|
-
0.1.
|
|
7
|
+
0.1.6.
|
|
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,
|
|
@@ -58,6 +58,15 @@ state. Only conservative, single-statement reads are eligible for automatic
|
|
|
58
58
|
retry after a connection failure; uncertain writes are never retried
|
|
59
59
|
automatically.
|
|
60
60
|
|
|
61
|
+
When an attached routing stream receives a `routing.shutdown` event, the
|
|
62
|
+
client drains the identified node, performs a REST bundle resynchronization,
|
|
63
|
+
and closes the retiring WebSocket with restart code `1012`. It then reconnects
|
|
64
|
+
through the configured endpoint, which should normally be the application's
|
|
65
|
+
load-balancer address. Reconnects, failovers, and measured reconnect delay are
|
|
66
|
+
included in telemetry. If the WebSocket remains unavailable, REST
|
|
67
|
+
resynchronization and bounded reconnect backoff continue until the caller
|
|
68
|
+
closes the stream.
|
|
69
|
+
|
|
61
70
|
The public client intentionally exposes SQL operations, health, routing,
|
|
62
71
|
lifecycle, and optional routing-event synchronization methods. REST and
|
|
63
72
|
WebSocket transports are adapters, not supervisor or CLI policy. Underlying
|
|
@@ -73,7 +82,8 @@ Applications may opt into generic in-memory telemetry with
|
|
|
73
82
|
`createDb({ ..., telemetry: true })`. Query counts, failures, retries,
|
|
74
83
|
in-flight work, and latency are exposed through `client.telemetry` and sent
|
|
75
84
|
over an attached routing stream once per second. Telemetry is observational
|
|
76
|
-
only; it does not carry SQL or credentials.
|
|
85
|
+
only; it does not carry SQL or credentials. Reconnect, failover, and cumulative
|
|
86
|
+
reconnect-delay counters are included in the telemetry snapshot.
|
|
77
87
|
|
|
78
88
|
`createMaterializer` supports bounded plaintext use for a caller-provided
|
|
79
89
|
operation. It creates a mode-restricted temporary file and removes its entire
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Release notes
|
|
2
2
|
|
|
3
|
+
## 0.1.6 — Graceful routing shutdown handoff
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Handles supervisor `routing.shutdown` events without exposing supervisor-
|
|
8
|
+
specific internals to applications.
|
|
9
|
+
- Drains the affected SQL node so in-flight work can finish while new work is
|
|
10
|
+
routed elsewhere.
|
|
11
|
+
- Closes the retiring WebSocket with restart code `1012` and reconnects through
|
|
12
|
+
the configured load-balancer endpoint.
|
|
13
|
+
- Performs an immediate REST routing-bundle resynchronization when the stream
|
|
14
|
+
is being retired or temporarily unavailable.
|
|
15
|
+
- Tracks intentional reconnects, failovers, and reconnect delay in telemetry.
|
|
16
|
+
- Adds regression coverage for shutdown events, close codes, reconnects, REST
|
|
17
|
+
fallback, node draining, and telemetry behavior.
|
|
18
|
+
|
|
19
|
+
### Validation
|
|
20
|
+
|
|
21
|
+
- 24 test suites pass with 116 tests.
|
|
22
|
+
- Syntax and diff validation pass.
|
|
23
|
+
|
|
3
24
|
## 0.1.5 — Telemetry and convention alignment
|
|
4
25
|
|
|
5
26
|
### Added
|
package/package.json
CHANGED
package/src/client/create-db.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
|
|
|
36
36
|
async transaction(callback) { const node = primaryPool.choose(); const connection = await node.getConnection(); try { await connection.beginTransaction(); const tx = { query: (sql, values) => connection.query(sql, values), execute: (sql, values) => connection.execute(sql, values) }; const result = await callback(tx); await connection.commit(); return result; } catch (error) { await connection.rollback().catch(() => {}); throw asSqlError(error); } finally { connection.release(); } },
|
|
37
37
|
async health(route = 'primary') { const started = now(); const selected = route === 'balanced' && balancedPool ? balancedPool : primaryPool; const nodes = await selected.health(); return { ok: nodes.some((node) => node.ok), route: selected === balancedPool ? 'balanced' : 'primary', nodes, latencyMs: now() - started }; },
|
|
38
38
|
async refresh(nextBundle) { const candidate = validateBundle(nextBundle); if (bundleExpired(candidate, now())) throw new Error('routing bundle is expired'); if (olderVersion(candidate.bundleVersion, activeBundle?.bundleVersion)) return { bundleVersion: activeBundle?.bundleVersion, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; const previous = [primaryPool, balancedPool]; const credentials = candidate.credentials ?? { username: primaryConfig.user, password: primaryConfig.password }; const writer = candidate.writer ?? candidate.routes.primary?.[0]; const reader = candidate.readers?.[0] ?? candidate.routes.balanced?.[0]; primaryConfig = validateProfile({ ...primaryConfig, host: writer?.host, port: writer?.port, user: credentials.username, password: credentials.password, database: candidate.database }, 'primary'); balancedConfig = reader ? validateProfile({ ...primaryConfig, host: reader.host, port: reader.port }, 'balanced') : undefined; activeBundle = candidate; primaryPool = makeRoute('primary', primaryConfig); balancedPool = balancedConfig ? makeRoute('balanced', balancedConfig) : null; await Promise.all(previous.filter(Boolean).map((pool) => pool.close())); return { bundleVersion: activeBundle.bundleVersion ?? null, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; },
|
|
39
|
-
async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); metrics?.start?.(stream); stream.setOnUpdate?.(async (event) => { const update = event.type === 'routing.update' ? event : event.type === 'routing.resync' ? event.bundle : undefined; if (update && (update.writer || update.routes?.primary?.length)) await client.refresh({ ...activeBundle, ...update, database: update.database ?? activeBundle?.database ?? primaryConfig.database, credentials: update.credentials ?? activeBundle?.credentials, routes: update.routes ?? activeBundle?.routes, bundleVersion: update.bundleVersion ?? update.version ?? activeBundle?.bundleVersion, expiresAt: update.expiresAt ?? activeBundle?.expiresAt ?? new Date(now() + 60000).toISOString() }); if (event.type === 'routing.drain' || event.type === 'routing.
|
|
39
|
+
async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); metrics?.start?.(stream); stream.setTelemetry?.(metrics); stream.setOnUpdate?.(async (event) => { const update = event.type === 'routing.update' ? event : event.type === 'routing.resync' ? event.bundle : undefined; if (update && (update.writer || update.routes?.primary?.length)) await client.refresh({ ...activeBundle, ...update, database: update.database ?? activeBundle?.database ?? primaryConfig.database, credentials: update.credentials ?? activeBundle?.credentials, routes: update.routes ?? activeBundle?.routes, bundleVersion: update.bundleVersion ?? update.version ?? activeBundle?.bundleVersion, expiresAt: update.expiresAt ?? activeBundle?.expiresAt ?? new Date(now() + 60000).toISOString() }); if (event.type === 'routing.drain' || event.type === 'routing.shutdown') for (const pool of [primaryPool, balancedPool].filter(Boolean)) (event.type === 'routing.drain' ? pool.drain : pool.drain)(event.node, drainTimeoutMs); if (event.type === 'routing.recovery') for (const pool of [primaryPool, balancedPool].filter(Boolean)) pool.recover(event.node, drainTimeoutMs); }); await stream.connect(); return () => stream.close?.(); },
|
|
40
40
|
drain(host, timeoutMs = drainTimeoutMs) { const effectiveTimeout = clientDrainTimeout(timeoutMs); const pools = [primaryPool, balancedPool].filter(Boolean); pools.forEach((pool) => pool.drain(host, effectiveTimeout)); return { host, timeoutMs: effectiveTimeout, wait: () => Promise.all(pools.map((pool) => pool.waitForIdle(effectiveTimeout))), forceClose: () => Promise.all(pools.map((pool) => pool.forceClose(host))) }; },
|
|
41
41
|
nodeStates() { return [primaryPool, balancedPool].filter(Boolean).flatMap((pool) => pool.nodes.map((node) => ({ host: node.host, port: node.port, route: pool === primaryPool ? 'primary' : 'balanced', state: node.state, active: node.active, available: node.available }))); },
|
|
42
42
|
setNodeAvailability(route, host, available) { const pool = route === 'balanced' ? balancedPool : primaryPool; pool?.setAvailability(host, available); },
|
package/src/index.d.ts
CHANGED
|
@@ -28,8 +28,8 @@ export interface CredentialProviderResult { user: string; password: string; }
|
|
|
28
28
|
export type CredentialProvider = (context: { database: string; identity: string | null; route: string }) => Promise<CredentialProviderResult> | CredentialProviderResult;
|
|
29
29
|
export type QueryFunction = (sql: string, values?: unknown) => Promise<any>;
|
|
30
30
|
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; inflight: number; totalLatencyMs: number; maxLatencyMs: number; avgLatencyMs: number; sentAt: string; }
|
|
32
|
-
export interface Telemetry { begin(): number; record(event?: { latencyMs?: number; failed?: boolean; retry?: boolean; reconnect?: boolean; failover?: boolean }): void; snapshot(): TelemetrySnapshot; start(stream: Pick<RoutingStream, 'sendTelemetry'>): void; stop(): void; }
|
|
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; }
|
|
32
|
+
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
33
|
|
|
34
34
|
export interface DbClient {
|
|
35
35
|
query(sql: string, values?: unknown, options?: DbOptions): Promise<unknown>;
|
|
@@ -53,6 +53,7 @@ export interface RoutingStream {
|
|
|
53
53
|
setOnUpdate(handler: (event: unknown) => void | Promise<void>): void;
|
|
54
54
|
close(): void;
|
|
55
55
|
sendTelemetry(payload: unknown): void;
|
|
56
|
+
setTelemetry?(telemetry?: Pick<Telemetry, 'recordReconnect'>): void;
|
|
56
57
|
state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number | string };
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -71,7 +72,7 @@ export function bundleNeedsRefresh(bundle: RoutingBundle, now?: number): boolean
|
|
|
71
72
|
export function createAdminSql(options: { query: QueryFunction }): { transaction<T>(work: (context: { query: QueryFunction }) => Promise<T>): Promise<T>; migration(statements?: string[]): Promise<unknown> };
|
|
72
73
|
export function createMigrationRunner(options: { query: QueryFunction; migrations?: Array<{ version: number; name: string; statements: string[] }> }): { status(): Promise<{ applied: unknown[] }>; migrate(): Promise<unknown> };
|
|
73
74
|
export function selectRouteNodes(options: { bundle: RoutingBundle; route?: 'primary' | 'balanced'; now?: number }): RoutingNode[];
|
|
74
|
-
export function createRoutingStream(options: { endpoint: string; token?: string; application?: string; fetchBundle: (application: string) => Promise<RoutingBundle>; onUpdate?: (event: unknown) => void; onError?: (error: unknown) => void; reconnectMs?: number; maxReconnectMs?: number; heartbeatMs?: number }): RoutingStream;
|
|
75
|
+
export function createRoutingStream(options: { endpoint: string; token?: string; application?: string; fetchBundle: (application: string) => Promise<RoutingBundle>; onUpdate?: (event: unknown) => void; onError?: (error: unknown) => void; reconnectMs?: number; maxReconnectMs?: number; heartbeatMs?: number; telemetry?: Pick<Telemetry, 'recordReconnect'> }): RoutingStream;
|
|
75
76
|
export function writerAssignment(bundle: RoutingBundle): WriterAssignment;
|
|
76
77
|
export function failoverNodes(bundle: RoutingBundle): WriterAssignment[];
|
|
77
78
|
export function compareBundleVersions(left: number | string | undefined, right: number | string | undefined): number;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { log as defaultLog } from '@eliware/common';
|
|
2
2
|
import { compareBundleVersions } from './bundle-version.mjs';
|
|
3
3
|
|
|
4
|
-
export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, heartbeatMs = 45000, now = () => Date.now() } = {}) {
|
|
4
|
+
export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, heartbeatMs = 45000, now = () => Date.now(), telemetry } = {}) {
|
|
5
5
|
if (!endpoint || typeof fetchBundle !== 'function') throw new TypeError('endpoint and fetchBundle are required');
|
|
6
|
-
let socket; let closed = false; let timer; let heartbeat; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected';
|
|
6
|
+
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;
|
|
7
7
|
const log = arguments[0]?.log ?? defaultLog;
|
|
8
8
|
const streamUrl = () => `${endpoint.replace(/^http/i, 'ws').replace(/\/$/, '')}/api/v1/routing/stream?application=${encodeURIComponent(application)}&token=${encodeURIComponent(token ?? '')}`;
|
|
9
9
|
async function fallback() { try { const bundle = await fetchBundle(application); 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 }); } }
|
|
@@ -12,10 +12,19 @@ export function createRoutingStream({ endpoint, token, application = 'default',
|
|
|
12
12
|
if (closed || typeof WebSocketImpl !== 'function') { await fallback(); schedule(); return; }
|
|
13
13
|
try {
|
|
14
14
|
socket = new WebSocketImpl(streamUrl());
|
|
15
|
-
socket.onopen = () => { mode = 'websocket'; delay = reconnectMs; heartbeat = setInterval(() => socket?.send?.(JSON.stringify({ type: 'heartbeat', sentAt: now() })), heartbeatMs); };
|
|
15
|
+
socket.onopen = () => { mode = 'websocket'; if (disconnectedAt !== undefined) { telemetry?.recordReconnect?.({ delayMs: Math.max(0, now() - disconnectedAt), failover: lastReconnectWasPlanned }); disconnectedAt = undefined; lastReconnectWasPlanned = false; } delay = reconnectMs; heartbeat = setInterval(() => socket?.send?.(JSON.stringify({ type: 'heartbeat', sentAt: now() })), heartbeatMs); };
|
|
16
16
|
socket.onmessage = async ({ data }) => {
|
|
17
17
|
try {
|
|
18
|
-
const event = JSON.parse(data);
|
|
18
|
+
const event = JSON.parse(data);
|
|
19
|
+
if (event.type === 'routing.shutdown') {
|
|
20
|
+
updateHandler?.(event);
|
|
21
|
+
plannedReconnect = true;
|
|
22
|
+
delay = reconnectMs;
|
|
23
|
+
await fallback();
|
|
24
|
+
socket?.close?.(1012, 'supervisor restarting');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const version = event.version;
|
|
19
28
|
if (version !== undefined && expectedVersion !== 0 && compareBundleVersions(version, expectedVersion) <= 0) return;
|
|
20
29
|
const numericVersion = Number(version); const numericExpected = Number(expectedVersion);
|
|
21
30
|
if (Number.isInteger(numericVersion) && Number.isInteger(numericExpected) && numericExpected > 0 && numericVersion > numericExpected + 1) await fallback();
|
|
@@ -24,8 +33,8 @@ export function createRoutingStream({ endpoint, token, application = 'default',
|
|
|
24
33
|
} catch (error) { onError?.(error); }
|
|
25
34
|
};
|
|
26
35
|
socket.onerror = (error) => { onError?.(error); };
|
|
27
|
-
socket.onclose = () => { clearInterval(heartbeat); heartbeat = undefined; socket = undefined; mode = 'disconnected'; if (!closed) { void fallback(); schedule(); } };
|
|
36
|
+
socket.onclose = () => { clearInterval(heartbeat); heartbeat = undefined; socket = undefined; mode = 'disconnected'; if (!closed) { disconnectedAt = now(); lastReconnectWasPlanned = plannedReconnect; if (!plannedReconnect) void fallback(); plannedReconnect = false; schedule(); } };
|
|
28
37
|
} catch (error) { mode = 'disconnected'; onError?.(error); await fallback(); schedule(); }
|
|
29
38
|
}
|
|
30
|
-
return { connect, sendTelemetry: (payload) => { if (socket?.readyState === 1) socket.send(JSON.stringify(payload)); }, setOnUpdate: (handler) => { updateHandler = handler; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); clearInterval(heartbeat); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion }) };
|
|
39
|
+
return { connect, sendTelemetry: (payload) => { if (socket?.readyState === 1) socket.send(JSON.stringify(payload)); }, setOnUpdate: (handler) => { updateHandler = handler; }, setTelemetry: (value) => { telemetry = value; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); clearInterval(heartbeat); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion }) };
|
|
31
40
|
}
|
package/src/telemetry.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export function createTelemetry({ application = 'default', intervalMs = 1000, now = () => Date.now(), setIntervalImpl = setInterval, clearIntervalImpl = clearInterval } = {}) {
|
|
2
|
-
const stats = { queries: 0, failures: 0, retries: 0, reconnects: 0, failoverCount: 0, inflight: 0, totalLatencyMs: 0, maxLatencyMs: 0 };
|
|
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
6
|
const snapshot = () => ({ type: 'client.telemetry', application, ...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
|
+
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; } };
|
|
8
9
|
}
|