@eliware/elera-lib 0.1.4 → 0.1.5

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 CHANGED
@@ -2,8 +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.4 alternative to `@eliware/mysql`; the
6
- existing package is intentionally unchanged.
5
+ backup, or GitOps policy. It is a v0.1.5 alternative to `@eliware/mysql`; the
6
+ existing package is intentionally unchanged. The current package version is
7
+ 0.1.5.
7
8
 
8
9
  `primary` is the preferred connection path. `balanced` is an optional alternate
9
10
  path. Both may accept writes; automatic routing sends only conservative,
@@ -68,6 +69,12 @@ schema, account, and grant checks. Neither API transports or orchestrates dump
68
69
  contents. The stream reports `websocket`, `rest`, or `disconnected` mode so
69
70
  callers can observe transport health without implementing transport policy.
70
71
 
72
+ Applications may opt into generic in-memory telemetry with
73
+ `createDb({ ..., telemetry: true })`. Query counts, failures, retries,
74
+ in-flight work, and latency are exposed through `client.telemetry` and sent
75
+ over an attached routing stream once per second. Telemetry is observational
76
+ only; it does not carry SQL or credentials.
77
+
71
78
  `createMaterializer` supports bounded plaintext use for a caller-provided
72
79
  operation. It creates a mode-restricted temporary file and removes its entire
73
80
  temporary directory in a `finally` block; this limits lifetime and cleanup but
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.1.5 — Telemetry and convention alignment
4
+
5
+ ### Added
6
+
7
+ - Adds opt-in generic in-memory client telemetry for query counts, failures,
8
+ retries, in-flight work, and latency.
9
+ - Sends telemetry over an attached routing stream once per second without
10
+ sending SQL text or credentials.
11
+ - Adds public TypeScript declarations and smoke coverage for the telemetry and
12
+ public client surface.
13
+ - Uses Snowflake identifiers for non-security temporary materialization paths.
14
+
15
+ ### Refactored
16
+
17
+ - Extracts telemetry timing into a focused client module.
18
+ - Reorganizes `create-db` tests under the mirrored `tests/client/create-db/`
19
+ hierarchy while retaining a small cross-cutting contract test.
20
+
21
+ ### Validation
22
+
23
+ - Tests pass with 100% statements, branches, functions, and lines coverage.
24
+ - TypeScript typecheck passes with zero lint warnings.
25
+
3
26
  ## 0.1.4 — Explicit writer and failover routing
4
27
 
5
28
  This release strengthens generic client-side routing for supervisor-provided
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@eliware/elera-lib",
3
- "version": "0.1.4",
4
- "description": "Generic MySQL and MariaDB client with resilient routing, client-side drains, and failover",
3
+ "version": "0.1.5",
4
+ "description": "Generic MySQL and MariaDB client with resilient routing, telemetry, client-side drains, and failover",
5
5
  "keywords": [
6
6
  "eliware",
7
7
  "elera",
@@ -11,6 +11,7 @@
11
11
  "database",
12
12
  "routing",
13
13
  "failover",
14
+ "telemetry",
14
15
  "websocket",
15
16
  "connection-pool"
16
17
  ],
@@ -53,6 +54,7 @@
53
54
  },
54
55
  "dependencies": {
55
56
  "@eliware/common": "^2.0.0",
57
+ "@eliware/snowflake": "^2.0.0",
56
58
  "mysql2": "^3.24.2"
57
59
  },
58
60
  "devDependencies": {
@@ -8,10 +8,12 @@ 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
+ import { createTelemetry } from '../telemetry.mjs';
12
+ import { createTimedOperation } from './telemetry-wrapper.mjs';
11
13
 
12
14
  const olderVersion = (candidate, current) => compareBundleVersions(candidate, current) < 0;
13
15
 
14
- export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, quarantineMs = 5000, drainTimeoutMs = 45000, now = () => Date.now() } = {}) {
16
+ export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, quarantineMs = 5000, drainTimeoutMs = 45000, now = () => Date.now(), telemetry } = {}) {
15
17
  if (!primary || typeof primary !== 'object') throw new TypeError('primary connection profile is required');
16
18
  const credentials = await resolveCredentials(credentialProvider, credentialContext(primary, { identity }));
17
19
  let primaryConfig = validateProfile({ ...primary, ...credentials }, 'primary');
@@ -26,20 +28,22 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
26
28
  let primaryPool = makeRoute('primary', primaryConfig);
27
29
  let balancedPool = balancedConfig || activeBundle?.routes?.balanced ? makeRoute('balanced', balancedConfig ?? primaryConfig) : null;
28
30
  const choose = (sql, options = {}) => options.connection ?? (routeFor(sql, options.route ?? routing) === 'balanced' && balancedPool ? balancedPool : primaryPool);
31
+ const metrics = telemetry === true ? createTelemetry({ application: bundle?.application ?? identity ?? 'default', now }) : telemetry;
32
+ const timed = createTimedOperation({ metrics, now });
29
33
  const client = {
30
- async query(sql, values, options) { 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') return balancedPool.query(sql, values); throw error; } },
34
+ 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; } }); },
31
35
  async execute(sql, values, options) { return choose(sql, options).execute(sql, values); },
32
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(); } },
33
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 }; },
34
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()) }; },
35
- async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); 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.recovery') for (const pool of [primaryPool, balancedPool].filter(Boolean)) (event.type === 'routing.drain' ? pool.drain : pool.recover)(event.node, drainTimeoutMs); }); await stream.connect(); return () => stream.close?.(); },
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.recovery') for (const pool of [primaryPool, balancedPool].filter(Boolean)) (event.type === 'routing.drain' ? pool.drain : pool.recover)(event.node, drainTimeoutMs); }); await stream.connect(); return () => stream.close?.(); },
36
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))) }; },
37
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 }))); },
38
42
  setNodeAvailability(route, host, available) { const pool = route === 'balanced' ? balancedPool : primaryPool; pool?.setAvailability(host, available); },
39
43
  bundle: () => activeBundle,
40
- async close() { await Promise.all([primaryPool.close(), balancedPool?.close()]); },
44
+ async close() { metrics?.stop?.(); await Promise.all([primaryPool.close(), balancedPool?.close()]); },
41
45
  classify: classifyQuery,
42
- config: { primary: redactedProfile(primaryConfig), balanced: balancedConfig && redactedProfile(balancedConfig) }
46
+ telemetry: metrics, config: { primary: redactedProfile(primaryConfig), balanced: balancedConfig && redactedProfile(balancedConfig) }
43
47
  };
44
48
  log.debug?.('SQL client created', { balanced: Boolean(balancedPool), routing });
45
49
  return client;
@@ -0,0 +1,15 @@
1
+ const emptyMetrics = { begin: () => undefined, record: () => {} };
2
+
3
+ export function createTimedOperation({ metrics = emptyMetrics, now = () => Date.now() } = {}) {
4
+ return async function timed(operation) {
5
+ const started = metrics.begin();
6
+ try {
7
+ const result = await operation();
8
+ metrics.record({ latencyMs: started === undefined ? 0 : now() - started });
9
+ return result;
10
+ } catch (error) {
11
+ metrics.record({ latencyMs: started === undefined ? 0 : now() - started, failed: true });
12
+ throw error;
13
+ }
14
+ };
15
+ }
package/src/index.d.ts CHANGED
@@ -28,12 +28,14 @@ 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
33
 
32
34
  export interface DbClient {
33
35
  query(sql: string, values?: unknown, options?: DbOptions): Promise<unknown>;
34
36
  execute(sql: string, values?: unknown, options?: DbOptions): Promise<unknown>;
35
37
  transaction<T>(callback: (transaction: Pick<DbClient, 'query' | 'execute'>) => Promise<T>): Promise<T>;
36
- health(route?: 'primary' | 'balanced'): Promise<{ ok: boolean; route: string; latencyMs: number }>;
38
+ health(route?: 'primary' | 'balanced'): Promise<{ ok: boolean; route: string; latencyMs: number; telemetry?: TelemetrySnapshot }>;
37
39
  close(): Promise<void>;
38
40
  refresh(bundle: RoutingBundle): Promise<{ bundleVersion: number | string | null; refreshRequired: boolean }>;
39
41
  bundle(): RoutingBundle | undefined;
@@ -43,17 +45,19 @@ export interface DbClient {
43
45
  drain(host: string, timeoutMs?: number): { host: string; timeoutMs: number; wait(): Promise<unknown[]>; forceClose(): Promise<unknown[]> };
44
46
  nodeStates(): Array<{ host: string; port: number; route: 'primary' | 'balanced'; state: 'ready' | 'draining' | 'unavailable' | 'recovering'; active: number; available: boolean }>;
45
47
  config: { primary: ConnectionProfile; balanced?: ConnectionProfile };
48
+ telemetry?: Telemetry;
46
49
  }
47
50
 
48
51
  export interface RoutingStream {
49
52
  connect(): Promise<void>;
50
53
  setOnUpdate(handler: (event: unknown) => void | Promise<void>): void;
51
54
  close(): void;
55
+ sendTelemetry(payload: unknown): void;
52
56
  state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number | string };
53
57
  }
54
58
 
55
- 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 }): Promise<DbClient>;
56
- export function createDbFromEnvironment(options?: { env?: Record<string, string | undefined>; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string }): Promise<DbClient>;
59
+ 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>;
60
+ 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>;
57
61
  export function classifyQuery(sql: unknown): 'primary' | 'balanced';
58
62
  export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balanced'): 'primary' | 'balanced';
59
63
  export function validateProfile(profile: ConnectionProfile, name?: string): ConnectionProfile;
@@ -76,3 +80,4 @@ export function clientDrainTimeout(timeoutMs?: number): number;
76
80
  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> };
77
81
  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> };
78
82
  export function createMaterializer(options?: Record<string, unknown>): unknown;
83
+ export function createTelemetry(options?: { application?: string; intervalMs?: number; now?: () => number; setIntervalImpl?: typeof setInterval; clearIntervalImpl?: typeof clearInterval }): Telemetry;
package/src/index.mjs CHANGED
@@ -15,3 +15,4 @@ export { CLIENT_DRAIN_TIMEOUT_MS, clientDrainTimeout } from './lifecycle/drain-p
15
15
  export { createQuiesceController } from './lifecycle/quiesce.mjs';
16
16
  export { createSqlVerifier } from './verification/sql.mjs';
17
17
  export { createMaterializer } from './lifecycle/materializer.mjs';
18
+ export { createTelemetry } from './telemetry.mjs';
@@ -1,9 +1,9 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { generate as generateSnowflake } from "@eliware/snowflake";
2
2
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
- export function createMaterializer({ makeTemp = mkdtemp, write = writeFile, remove = rm, id = randomUUID } = {}) {
6
+ export function createMaterializer({ makeTemp = mkdtemp, write = writeFile, remove = rm, id = generateSnowflake } = {}) {
7
7
  return {
8
8
  async withFile(content, operation) {
9
9
  if (typeof operation !== "function") throw new TypeError("materializer operation is required");
@@ -27,5 +27,5 @@ export function createRoutingStream({ endpoint, token, application = 'default',
27
27
  socket.onclose = () => { clearInterval(heartbeat); heartbeat = undefined; socket = undefined; mode = 'disconnected'; if (!closed) { void fallback(); schedule(); } };
28
28
  } catch (error) { mode = 'disconnected'; onError?.(error); await fallback(); schedule(); }
29
29
  }
30
- return { connect, setOnUpdate: (handler) => { updateHandler = handler; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); clearInterval(heartbeat); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion }) };
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 }) };
31
31
  }
@@ -0,0 +1,8 @@
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 };
3
+ let timer;
4
+ const begin = () => { stats.inflight += 1; return now(); };
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() });
7
+ return { begin, record, snapshot, start(stream) { if (timer) return; timer = setIntervalImpl(() => stream.sendTelemetry?.(snapshot()), intervalMs); timer.unref?.(); }, stop() { if (timer) clearIntervalImpl(timer); timer = undefined; } };
8
+ }