@eliware/elera-lib 0.1.6 → 0.1.7

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,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.6 alternative to `@eliware/mysql`; the
5
+ backup, or GitOps policy. It is a v0.1.7 alternative to `@eliware/mysql`; the
6
6
  existing package is intentionally unchanged. The current package version is
7
- 0.1.6.
7
+ 0.1.7.
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,
@@ -60,12 +60,21 @@ automatically.
60
60
 
61
61
  When an attached routing stream receives a `routing.shutdown` event, the
62
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.
63
+ and closes the retiring WebSocket with restart code `1012`. If the event
64
+ contains `loadBalancerEndpoint`, that endpoint replaces the current endpoint
65
+ before resynchronization and reconnect. `reconnectDeadlineMs` bounds the
66
+ planned reconnect window; after it expires, no new reconnect is scheduled.
67
+ Reconnects, failovers, and measured reconnect delay are included in telemetry.
68
+ If the WebSocket remains unavailable before the deadline, REST
69
+ resynchronization and bounded reconnect backoff continue until the deadline or
70
+ until the caller closes the stream.
71
+
72
+ Routing events are validated before application. Shutdown events may include
73
+ `node`, `reason`, `reconnect`, `reconnectDeadlineMs`, and
74
+ `loadBalancerEndpoint`; invalid event fields are reported through `onError` and
75
+ do not change routing state. `routing.update` replaces the writer and reader
76
+ pools atomically, while `routing.drain` excludes only the named node from new
77
+ work and allows active operations to finish.
69
78
 
70
79
  The public client intentionally exposes SQL operations, health, routing,
71
80
  lifecycle, and optional routing-event synchronization methods. REST and
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.1.7 — Routing shutdown contract completion
4
+
5
+ ### Added
6
+
7
+ - Validates supervisor routing-control events before applying them.
8
+ - Supports node-specific shutdown events so only the retiring SQL node is
9
+ drained by the client.
10
+ - Honors an optional `loadBalancerEndpoint` supplied during shutdown before
11
+ REST resynchronization and WebSocket reconnect.
12
+ - Honors `reconnectDeadlineMs` and stops scheduling reconnect attempts after
13
+ the deadline expires.
14
+ - Exposes the active endpoint and reconnect deadline in routing-stream state.
15
+ - Publishes typed routing-event declarations for TypeScript consumers.
16
+
17
+ ### Validation
18
+
19
+ - Adds regression coverage for node-specific shutdown draining, endpoint
20
+ replacement, deadline expiry, malformed events, and reconnect behavior.
21
+ - Maintains 100% statements, branches, functions, and lines coverage with
22
+ zero lint warnings.
23
+ - Typecheck and diff validation pass.
24
+
3
25
  ## 0.1.6 — Graceful routing shutdown handoff
4
26
 
5
27
  ### Added
@@ -18,7 +40,8 @@
18
40
 
19
41
  ### Validation
20
42
 
21
- - 24 test suites pass with 116 tests.
43
+ - The complete test suite passes with 100% statements, branches, functions,
44
+ and lines coverage.
22
45
  - Syntax and diff validation pass.
23
46
 
24
47
  ## 0.1.5 — Telemetry and convention alignment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eliware/elera-lib",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Generic MySQL and MariaDB client with resilient routing, telemetry, client-side drains, and failover",
5
5
  "keywords": [
6
6
  "eliware",
package/src/index.d.ts CHANGED
@@ -54,9 +54,12 @@ export interface RoutingStream {
54
54
  close(): void;
55
55
  sendTelemetry(payload: unknown): void;
56
56
  setTelemetry?(telemetry?: Pick<Telemetry, 'recordReconnect'>): void;
57
- state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number | string };
57
+ state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number | string; endpoint: string; reconnectDeadlineAt?: number };
58
58
  }
59
59
 
60
+ 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 };
61
+ export function validateRoutingEvent(event: unknown): RoutingEvent;
62
+
60
63
  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>;
61
64
  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>;
62
65
  export function classifyQuery(sql: unknown): 'primary' | 'balanced';
package/src/index.mjs CHANGED
@@ -9,6 +9,7 @@ export { createAdminSql } from './admin/sql.mjs';
9
9
  export { createMigrationRunner } from './admin/migrations.mjs';
10
10
  export { selectRouteNodes } from './routing/node-set.mjs';
11
11
  export { createRoutingStream } from './routing/stream-client.mjs';
12
+ export { validateRoutingEvent } from './routing/event-contract.mjs';
12
13
  export { writerAssignment, failoverNodes } from './routing/assignment.mjs';
13
14
  export { compareBundleVersions } from './routing/bundle-version.mjs';
14
15
  export { CLIENT_DRAIN_TIMEOUT_MS, clientDrainTimeout } from './lifecycle/drain-policy.mjs';
@@ -0,0 +1,11 @@
1
+ const eventTypes = new Set(['routing.update', 'routing.resync', 'routing.drain', 'routing.recovery', 'routing.shutdown']);
2
+
3
+ export function validateRoutingEvent(event) {
4
+ if (!event || typeof event !== 'object' || !eventTypes.has(event.type)) throw new TypeError('unsupported routing event');
5
+ if (event.type === 'routing.shutdown') {
6
+ if (event.node !== undefined && typeof event.node !== 'string') throw new TypeError('routing shutdown node must be a string');
7
+ if (event.reconnectDeadlineMs !== undefined && (!Number.isFinite(Number(event.reconnectDeadlineMs)) || Number(event.reconnectDeadlineMs) < 0)) throw new TypeError('routing shutdown deadline must be non-negative');
8
+ if (event.loadBalancerEndpoint !== undefined && typeof event.loadBalancerEndpoint !== 'string') throw new TypeError('routing shutdown endpoint must be a string');
9
+ }
10
+ return event;
11
+ }
@@ -1,23 +1,26 @@
1
1
  import { log as defaultLog } from '@eliware/common';
2
2
  import { compareBundleVersions } from './bundle-version.mjs';
3
+ import { validateRoutingEvent } from './event-contract.mjs';
3
4
 
4
5
  export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, heartbeatMs = 45000, now = () => Date.now(), telemetry } = {}) {
5
6
  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'; let plannedReconnect = false; let lastReconnectWasPlanned = false; let disconnectedAt;
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;
7
8
  const log = arguments[0]?.log ?? defaultLog;
8
9
  const streamUrl = () => `${endpoint.replace(/^http/i, 'ws').replace(/\/$/, '')}/api/v1/routing/stream?application=${encodeURIComponent(application)}&token=${encodeURIComponent(token ?? '')}`;
9
10
  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 }); } }
10
- function schedule() { if (closed || timer) return; timer = setTimeout(() => { timer = undefined; void connect(); }, delay); delay = Math.min(maxReconnectMs, delay * 2); }
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); }
11
12
  async function connect() {
12
13
  if (closed || typeof WebSocketImpl !== 'function') { await fallback(); schedule(); return; }
13
14
  try {
14
15
  socket = new WebSocketImpl(streamUrl());
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
+ socket.onopen = () => { mode = 'websocket'; reconnectDeadlineAt = undefined; 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
17
  socket.onmessage = async ({ data }) => {
17
18
  try {
18
- const event = JSON.parse(data);
19
+ const event = validateRoutingEvent(JSON.parse(data));
19
20
  if (event.type === 'routing.shutdown') {
20
21
  updateHandler?.(event);
22
+ if (typeof event.loadBalancerEndpoint === 'string' && event.loadBalancerEndpoint) endpoint = event.loadBalancerEndpoint;
23
+ reconnectDeadlineAt = Number.isFinite(Number(event.reconnectDeadlineMs)) ? now() + Math.max(0, Number(event.reconnectDeadlineMs)) : undefined;
21
24
  plannedReconnect = true;
22
25
  delay = reconnectMs;
23
26
  await fallback();
@@ -36,5 +39,5 @@ export function createRoutingStream({ endpoint, token, application = 'default',
36
39
  socket.onclose = () => { clearInterval(heartbeat); heartbeat = undefined; socket = undefined; mode = 'disconnected'; if (!closed) { disconnectedAt = now(); lastReconnectWasPlanned = plannedReconnect; if (!plannedReconnect) void fallback(); plannedReconnect = false; schedule(); } };
37
40
  } catch (error) { mode = 'disconnected'; onError?.(error); await fallback(); schedule(); }
38
41
  }
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 }) };
42
+ 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, endpoint, reconnectDeadlineAt }) };
40
43
  }