@winkjs/composer 0.6.0 → 0.8.0

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.
Files changed (39) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/src/composer.js +1 -1
  4. package/src/core/emitter-manager/mqtt/connection.js +209 -0
  5. package/src/core/emitter-manager/mqtt/constants.js +43 -29
  6. package/src/core/emitter-manager/mqtt/emitter.js +103 -658
  7. package/src/core/emitter-manager/mqtt/health.js +151 -0
  8. package/src/core/emitter-manager/mqtt/index.js +68 -4
  9. package/src/core/emitter-manager/mqtt/publish.js +292 -0
  10. package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
  11. package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
  12. package/src/core/emitter-manager/terminal/index.js +16 -1
  13. package/src/core/env-vars.js +92 -24
  14. package/src/core/logger/index.js +1 -1
  15. package/src/core/source-manager/mqtt/client.js +197 -63
  16. package/src/core/source-manager/mqtt/dedup.js +16 -11
  17. package/src/core/source-manager/mqtt/index.js +32 -5
  18. package/src/core/source-manager/mqtt/status.js +168 -56
  19. package/src/core/storage-manager/questdb/address-policy.js +264 -0
  20. package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
  21. package/src/core/storage-manager/questdb/flush-engine.js +852 -0
  22. package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
  23. package/src/core/storage-manager/questdb/index.js +542 -862
  24. package/src/core/storage-manager/questdb/persist-plan.js +128 -167
  25. package/src/core/storage-manager/questdb/resolve-options.js +266 -0
  26. package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
  27. package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
  28. package/src/core/test-utils/tcp-proxy.js +194 -1
  29. package/src/core/utils/address/index.js +362 -0
  30. package/src/core/utils/address/probe.js +210 -0
  31. package/src/core/utils/callback-guard/index.js +23 -2
  32. package/src/core/utils/clock/index.js +34 -0
  33. package/src/core/utils/jitter/index.js +51 -0
  34. package/src/core/utils/line-rate/index.js +104 -0
  35. package/src/core/utils/quantize/index.js +43 -19
  36. package/src/core/wiring/wire-emitters.js +3 -1
  37. package/src/core/wiring/wire-storages.js +10 -2
  38. package/src/flow/flow.js +24 -3
  39. package/src/flow/run.js +24 -2
@@ -15,6 +15,20 @@
15
15
  * closes that gap by construction; the composer-owned WAL that restores
16
16
  * crash durability is ADR-021's planned successor.
17
17
  *
18
+ * The adapter is one closure factory per concern, the shape QuestDB's
19
+ * flush engine uses. This file is the factory. It resolves the
20
+ * configuration, builds the state, opens the client, and assembles the
21
+ * handle from the modules beside it:
22
+ * - `resolve-config.js` validates every option in one fixed order and
23
+ * arms the three callbacks through the shared guard.
24
+ * - `connection.js` opens the client, waits for the first connack, and
25
+ * keeps `state.connected` and the counters current.
26
+ * - `health.js` reads pressure and health, and signals backpressure.
27
+ * - `publish.js` is the hot path, `publishNow`.
28
+ * - `shutdown-drain.js` is the latched drain-then-close shutdown.
29
+ * Each module captures what it needs at setup. Nothing moves onto a
30
+ * per-message argument list.
31
+ *
18
32
  * DESIGN DECISIONS:
19
33
  *
20
34
  * 1. FIRE-AND-FORGET WITH QoS 1
@@ -37,6 +51,12 @@
37
51
  * 3. MESSAGE EXPIRY (MQTT v5)
38
52
  * - Configurable per message type
39
53
  * - Prevents flooding backend after extended offline periods
54
+ * - The type lookup is a null-prototype table. A prototype key such
55
+ * as 'constructor' would hand the library a function, and
56
+ * mqtt-packet then destroys the stream with an error that carries
57
+ * no code. mqtt.js swallows such an error, so no handler sees it,
58
+ * and the poison packet replays on every reconnect. An unknown
59
+ * key falls back to the default expiry instead.
40
60
  *
41
61
  * 4. NO FORCED IDENTITY / OPTIONAL WILL — unchanged transport behavior.
42
62
  *
@@ -48,8 +68,10 @@
48
68
  * reachable broker the real wait is one connack round trip; on an
49
69
  * unreachable one the flow starts with `connected: false` and the
50
70
  * client keeps retrying in the background.
51
- * - Return shape: the handle itself at grace 0, otherwise a Promise
52
- * of it. The wiring layer awaits either (ADR-018).
71
+ * - Return shape: always a Promise of the handle, as QuestDB's
72
+ * factory returns. At grace 0 the promise resolves without a
73
+ * wait. A configuration refusal arrives as a rejection carrying
74
+ * the classified code. The wiring layer awaits it (ADR-018).
53
75
  * - This replaced the wire-time `sleep(240)` that once papered over
54
76
  * the pre-connack loss ADR-021 eliminated.
55
77
  *
@@ -61,7 +83,12 @@
61
83
  * when supplied (ctx = `{ topic }`); without a handler the adapter
62
84
  * surfaces the failure via `Promise.reject(deliveryErr)` — loud failure
63
85
  * beats silent loss (parity with QuestDB). `onCritical` is
64
- * reserved for the high-pressure `QUEUE_CRITICAL` warning (no loss yet).
86
+ * reserved for the high-pressure `QUEUE_CRITICAL` warning (no loss
87
+ * yet). It fires once per climb past 0.8 and re-arms below 0.66;
88
+ * `onBackpressure` reports the pressure after every acknowledgment.
89
+ * - `flush( { timeout }? )` waits for every accepted message to settle,
90
+ * closing nothing and refusing nothing. On its deadline it rejects
91
+ * `DELIVERY_FAILED` with `pending: { count }` (ADR-018 §6).
65
92
  * - `getPressure()` returns unacked / maxQueueSize in `[0, 1]` (sync,
66
93
  * O(1), allocation-free per ADR-018).
67
94
  * - `getHealth()` returns the ADR-018 health floor `{status, connected,
@@ -100,7 +127,12 @@
100
127
  * - `SHUTTING_DOWN` — emitter is mid-shutdown; new publishes are dropped.
101
128
  * - `INVALID_CONFIG` — setup-time; missing or malformed config field
102
129
  * (brokerUrl, codec.pack, callback type, connectGraceMs). On the
103
- * thrown TypeError per ADR-018's fail-fast setup rule.
130
+ * thrown TypeError per ADR-018's fail-fast setup rule. Includes a
131
+ * `brokerUrl` whose host is `localhost` (ADR-030): the name can
132
+ * resolve to two addresses and the broker may listen on only one.
133
+ * The message names the literal to set. Refused before the client
134
+ * is created, at the schema (flow definition) and here (the
135
+ * `MQTT_BROKER_URL` fallback and direct callers).
104
136
  * - `SHUTDOWN_TIMEOUT` — shutdown closed with unacknowledged messages;
105
137
  * carries `dropped: { count }` (the exact counter value). Fires whether
106
138
  * connected or not: with no disk store, nothing survives the process,
@@ -113,240 +145,49 @@
113
145
  * keeps publishing and each fault becomes one classified console
114
146
  * line. Fix the callback; the line names it and carries the detail.
115
147
  *
116
- * @module mqtt-emitter
117
- */
118
-
119
- import mqtt from 'mqtt';
120
- import crypto from 'crypto';
121
- import { ENV_VARS } from '../../env-vars.js';
122
- import {
123
- QOS,
124
- MQTT_CONFIG,
125
- WINK_NAMESPACE,
126
- QUEUE_CRITICAL_THRESHOLD,
127
- MESSAGE_EXPIRY,
128
- DEFAULT_MAX_QUEUE_SIZE,
129
- MQTT_INFLIGHT_ID_LIMIT
130
- } from './constants.js';
131
- import { wrapCallback } from '../../utils/callback-guard/index.js';
132
- import { logger } from '../../logger/index.js';
133
-
134
- /**
135
- * Pre-flight reject threshold on store pressure.
136
- * 0.9 = 10% headroom for in-flight drain on shutdown, plus a clean band
137
- * between the yellow-health threshold (0.66, shared with the yield level the
138
- * ADR-020 Draft proposes) and store exhaustion (1.0). At or above this,
139
- * `publishNow` returns `STORAGE_FULL` synchronously; the message is not buffered.
140
- * @type {number}
141
- */
142
- const STORAGE_PRESSURE_LIMIT = 0.9;
143
-
144
- /**
145
- * Pressure threshold above which `getHealth().status` elevates to at least
146
- * 'yellow' (uniform with Terminal/QuestDB). The value matches the
147
- * pressure-aware-yield design (ADR-020, still a Draft). That design
148
- * proposes 0.66 as the pressure level where the flow would start yielding
149
- * to let sinks drain; a benchmark still has to confirm the number. Today
150
- * the yield trigger is time-only (ADR-024), so this alignment is
151
- * forward-looking.
152
- * @type {number}
153
- */
154
- const HEALTH_PRESSURE_YELLOW_THRESHOLD = 0.66;
155
-
156
- /**
157
- * Singleton success result reused on every successful publish. Hot-path zero
158
- * allocation per ADR-013 / ADR-004. Plain literal — not frozen.
159
- * @type {{ok: true}}
160
- */
161
- const RESULT_OK = { ok: true };
162
-
163
- /**
164
- * Singleton error result for the pre-flight pressure-limit reject. Reused on
165
- * every occurrence; rare in healthy operation (pressure this close to the
166
- * limit means delivery has stopped draining), but a defined contract path.
167
- * @type {{ok: false, error: {code: string, message: string}}}
168
- */
169
- const ERR_STORAGE_FULL = {
170
- ok: false,
171
- error: {
172
- code: 'STORAGE_FULL',
173
- message: 'Store at or above pressure limit (0.9) — cannot accept message'
174
- }
175
- };
176
-
177
- /**
178
- * Singleton error result for publishes attempted while shutdown is in progress.
179
- * @type {{ok: false, error: {code: string, message: string}}}
180
- */
181
- const ERR_SHUTTING_DOWN = {
182
- ok: false,
183
- error: {
184
- code: 'SHUTTING_DOWN',
185
- message: 'Emitter is shutting down — message dropped'
186
- }
187
- };
188
-
189
- /**
190
- * Build a config-error TypeError tagged for ADR-018 fail-fast setup routing.
191
- * @param {string} message
192
- * @returns {TypeError}
193
- */
194
- const invalidConfig = function ( message ) {
195
- const err = new TypeError( 'winkComposer/mqttEmitter: ' + message );
196
- err.code = 'INVALID_CONFIG';
197
- return err;
198
- };
199
-
200
- /**
201
- * Throw classified when an optional callback option is set to a non-function.
202
- * @param {*} value - the configured option value
203
- * @param {string} name - the option name for the error message
204
- */
205
- const assertOptionalCallback = function ( value, name ) {
206
- if ( value !== undefined && typeof value !== 'function' ) {
207
- throw invalidConfig( `${name} must be a function` );
208
- }
209
- };
210
-
211
- /**
212
- * Validate and build the mqtt.js will (last-will testament) options.
213
- *
214
- * Direct createEmitter callers bypass the DSL schema, so the factory
215
- * checks the will shape itself, and the payload encoding runs behind
216
- * the same classified-throw rule as every other setup failure
217
- * (ADR-018, fail-fast setup) — a raw codec TypeError would reach the
218
- * caller unclassified.
148
+ * Console classification (a token on a log line, not an `err.code`):
149
+ * - `DELIVERY_HEALTH` — the link changed state. One line per change,
150
+ * with or without `debug`: going offline at error, the next connack
151
+ * at warn with the outage length. Both carry the in-flight count.
152
+ * Nothing prints while a state persists (see `connection.js`).
153
+ * - `CONNECT_FAILED` — the client's `error` event, at warn, with
154
+ * the reason (a refused connect, a connack timeout). Bounded: two
155
+ * per episode in full, then one summary a minute.
156
+ * - `ADDRESS_IS_NAME` — the `brokerUrl` host is a name other than
157
+ * `localhost`. One `logger.warn` line at setup, before the client is
158
+ * created (ADR-030). A name is allowed and the emitter proceeds; the
159
+ * line tells the operator that only a literal address is immune to a
160
+ * resolver that changes its answer under a running process. This
161
+ * emitter refuses and warns; it does not probe, because its posture
162
+ * is recovering (ADR-018 §5).
219
163
  *
220
- * @param {Object} will - config.will as supplied
221
- * @param {Object} codec - config.codec (pack + contentType)
222
- * @returns {Object} the mqtt.js connect-options `will` object
164
+ * @module mqtt-emitter
223
165
  */
224
- const buildWillOptions = function ( will, codec ) {
225
- if ( typeof will.topic !== 'string' || will.topic.length === 0 ) {
226
- throw invalidConfig( 'will.topic is required — a non-empty string' );
227
- }
228
- if ( will.message === undefined || will.message === null ) {
229
- throw invalidConfig( 'will.message is required' );
230
- }
231
- let willPayload;
232
- try {
233
- willPayload = Buffer.from( codec.pack( will.message ) );
234
- } catch ( packErr ) {
235
- throw invalidConfig( `will.message could not be encoded by the codec: ${packErr.message}` );
236
- }
237
- const willOptions = {
238
- topic: will.topic,
239
- payload: willPayload,
240
- // `??`, not `||`: 0 is a valid QoS.
241
- qos: will.qos ?? QOS,
242
- retain: will.retain !== false, // Default true
243
- properties: {
244
- contentType: codec.contentType
245
- }
246
- };
247
- if ( codec.payloadFormatIndicator === 1 ) {
248
- willOptions.properties.payloadFormatIndicator = true;
249
- }
250
- return willOptions;
251
- };
252
166
 
253
- /**
254
- * Bounded wait for the client's first 'connect' event (the broker's
255
- * connection acknowledgment). Resolves when the event fires or when
256
- * graceMs elapses, whichever comes first. Expiry is NOT an error —
257
- * the startup posture stays 'recovering' (ADR-018): the caller gets
258
- * a working handle that reports `connected: false` while mqtt.js
259
- * retries in the background. The one-shot listener is removed on
260
- * expiry so a late connack reaches only the permanent state handler;
261
- * the timer is cleared on connect so nothing pins the event loop.
262
- *
263
- * @param {Object} client - mqtt.js client (EventEmitter surface)
264
- * @param {number} graceMs - positive wait budget, milliseconds
265
- * @returns {Promise<void>} resolves on first connect or expiry; never rejects
266
- */
267
- const waitForFirstConnect = function ( client, graceMs ) {
268
- return new Promise( ( resolve ) => {
269
- let timer = null;
270
- const onConnect = function () {
271
- clearTimeout( timer );
272
- resolve();
273
- };
274
- timer = setTimeout( () => {
275
- client.removeListener( 'connect', onConnect );
276
- resolve();
277
- }, graceMs );
278
- client.once( 'connect', onConnect );
279
- } );
280
- }; // waitForFirstConnect()
167
+ import { resolveConfig } from './resolve-config.js';
168
+ import { openClient, waitForFirstConnect, attachConnectionHandlers } from './connection.js';
169
+ import { createHealth } from './health.js';
170
+ import { createPublishNow } from './publish.js';
171
+ import { createShutdown } from './shutdown-drain.js';
281
172
 
282
173
  /**
283
174
  * Create production MQTT emitter.
284
175
  *
285
- * Validation is synchronous — bad config throws before any side
286
- * effect. The return shape depends on the effective grace:
287
- * `connectGraceMs === 0` returns the handle directly (today's callers
288
- * that need synchronous creation pass 0); otherwise returns a Promise
289
- * resolving to the handle after the first connack or the grace budget.
290
- * The wiring layer awaits either shape (ADR-018).
176
+ * Validation runs before any side effect. Bad config rejects the
177
+ * returned promise with a classified `INVALID_CONFIG` error, so a
178
+ * refusal and a handle arrive through the same channel, the way
179
+ * QuestDB's factory works. With a positive grace the promise resolves
180
+ * after the first connack or the grace budget; at grace 0 it resolves
181
+ * without a wait. The wiring layer awaits it (ADR-018).
291
182
  *
292
183
  * @param {Object} config - emitter config (see configSchema in index.js)
293
- * @returns {Object|Promise<Object>} the emitter handle, or a Promise of it
184
+ * @returns {Promise<Object>} the emitter handle
294
185
  */
295
- export const createEmitter = function ( config ) {
296
- // Per ADR-018, setup-time throws carry classified err.code.
297
- //
298
- // brokerUrl: `??` so explicit '' is the user's choice (not "fall back
299
- // to env"); `.trim()` rejects whitespace-only. Symmetric with QuestDB,
300
- // which throws on `ilpUrl: ''`.
301
- const rawBrokerUrl = config.brokerUrl ?? ENV_VARS.mqttBrokerUrl;
302
- const brokerUrl = typeof rawBrokerUrl === 'string' ? rawBrokerUrl.trim() : '';
303
- if ( !brokerUrl ) {
304
- throw invalidConfig( 'brokerUrl required — set a non-empty string in .emitter() config or MQTT_BROKER_URL env var' );
305
- }
306
- if ( !config.codec ) {
307
- throw invalidConfig( 'config.codec is required' );
308
- }
309
- if ( typeof config.codec.pack !== 'function' ) {
310
- throw invalidConfig( 'config.codec must have a pack() function' );
311
- }
312
- assertOptionalCallback( config.onDeliveryFailure, 'onDeliveryFailure' );
313
- assertOptionalCallback( config.onCritical, 'onCritical' );
314
- assertOptionalCallback( config.onBackpressure, 'onBackpressure' );
315
-
316
- // The three notification callbacks are armed by the shared
317
- // callback guard. They were validated raw above and are wrapped
318
- // once here. The wrap matters because all three run inside
319
- // mqtt.js's publish ack chain, where a user throw would land in
320
- // the client library (ADR-018). Each wrap is null when the
321
- // callback is absent, so every no-handler path below keeps its
322
- // exact meaning. That includes the deliberate unhandled-rejection
323
- // escape hatch for an unhandled delivery failure.
324
- const reportCallbackFault = function ( severity, name, detail ) {
325
- logger.error(
326
- `winkComposer/mqttEmitter: user callback ${name} failed [CALLBACK_FAILED]: ${detail}`
327
- );
328
- };
329
- const onDeliveryFailure = wrapCallback( config.onDeliveryFailure, {
330
- name: 'onDeliveryFailure', severity: 'red', report: reportCallbackFault
331
- } );
332
- const onCritical = wrapCallback( config.onCritical, {
333
- name: 'onCritical', severity: 'red', report: reportCallbackFault
334
- } );
335
- const onBackpressure = wrapCallback( config.onBackpressure, {
336
- name: 'onBackpressure', severity: 'yellow', report: reportCallbackFault
337
- } );
338
-
339
- // First-connack grace: explicit config → MQTT_CONNECT_GRACE_MS env
340
- // fallback → 500 ms default (ADR-018 precedence; the fallback lives
341
- // here in the factory body, never as a schema sigil). `??` keeps an
342
- // explicit 0 — "hand the handle back immediately" — from falling
343
- // through to the env value. Number.isInteger also rejects Infinity,
344
- // so the wait is bounded by construction. Validated here, before
345
- // mqtt.connect opens anything (fail-fast setup per ADR-018).
346
- const connectGraceMs = config.connectGraceMs ?? ENV_VARS.mqttConnectGraceMs;
347
- if ( !Number.isInteger( connectGraceMs ) || connectGraceMs < 0 ) {
348
- throw invalidConfig( 'connectGraceMs must be a non-negative integer (milliseconds); 0 disables the first-connect wait' );
349
- }
186
+ export const createEmitter = async function ( config ) {
187
+ // Per ADR-018, setup-time refusals carry a classified err.code and
188
+ // run before any side effect. `resolveConfig` throws in one fixed
189
+ // order, and this async function turns each throw into a rejection.
190
+ const resolved = resolveConfig( config );
350
191
 
351
192
  // Core state. `unacked` is THE counter (ADR-021): +1 on accepted
352
193
  // publish, -1 when the publish callback fires. Pressure, refusal,
@@ -365,446 +206,50 @@ export const createEmitter = function ( config ) {
365
206
  }
366
207
  };
367
208
 
368
- // Broker URLs may carry credentials (mqtt://user:pass@host). Debug
369
- // logs print this redacted form, so a debug flag flipped on in
370
- // production cannot leak the password into whatever collects stdout.
371
- const redactedBrokerUrl = brokerUrl.replace( /\/\/[^@/]*@/, '//***@' );
372
-
373
- // Generate unique client ID if not provided
374
- const clientId = config.clientId || `wink-${Date.now()}-${Math.random().toString( 36 ).slice( 2, 9 )}`;
375
-
376
- // The unacked cap. Clamped to the 16-bit packet-id space: every
377
- // unacknowledged QoS-1 message holds a packet id, so one connection
378
- // can never carry more than 65,535; 60,000 leaves working headroom
379
- // (same clamp the LevelDB store applied, kept with the same warning).
380
- const requestedQueueSize = config.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE;
381
- if ( requestedQueueSize > MQTT_INFLIGHT_ID_LIMIT ) {
382
- logger.warn(
383
- `winkComposer/mqttEmitter: maxQueueSize ${requestedQueueSize} exceeds the MQTT ` +
384
- `packet-id ceiling — clamped to ${MQTT_INFLIGHT_ID_LIMIT}`
385
- );
386
- }
387
- const maxQueueSize = Math.min( requestedQueueSize, MQTT_INFLIGHT_ID_LIMIT );
388
-
389
- // Build MQTT options.
390
- //
391
- // NO outgoingStore: the client runs its default SYNCHRONOUS memory
392
- // store (ADR-021). mqtt.js erases its packet-id bookkeeping on every
393
- // connack and rebuilds it from a store snapshot asynchronously; with
394
- // an asynchronous store, writes still in flight at that instant are
395
- // invisible to the snapshot and get overwritten by id reuse — real
396
- // QoS-1 loss, measured scaling with store write latency. The
397
- // synchronous store makes the gap zero-width (validated 2026-07-09,
398
- // nine-run matrix incl. reconnects at 45k in flight).
399
- //
400
- // messageIdProvider: the client's default provider cycles the 16-bit
401
- // id space with NO in-use check — at 14 k msg/s it wraps every ~5 s,
402
- // and an id whose PUBACK never arrived gets reassigned, overwriting
403
- // the unacked packet's memory-store entry (same key). Measured at
404
- // about one lost publish per 700,000 under sustained load.
405
- // UniqueMessageIdProvider never reissues an in-use id.
406
- const mqttOptions = {
407
- ...MQTT_CONFIG,
408
- clientId,
409
- messageIdProvider: new mqtt.UniqueMessageIdProvider()
410
- };
411
-
412
- // Only add will if explicitly provided (validated + encoded by the
413
- // helper above; classified throws per ADR-018)
414
- if ( config.will ) {
415
- mqttOptions.will = buildWillOptions( config.will, config.codec );
416
- }
417
-
418
- // Create MQTT client (injectable for testing). mqtt.connect throws
419
- // its own raw TypeError on a malformed url; classify it like every
420
- // other setup failure. The error text uses the redacted url — a
421
- // malformed url can still carry credentials.
422
- const connect = config.mqttConnectFn || mqtt.connect;
423
- let client;
424
- try {
425
- client = connect( brokerUrl, mqttOptions );
426
- } catch ( connectErr ) {
427
- throw invalidConfig( `brokerUrl rejected by the MQTT client ('${redactedBrokerUrl}'): ${connectErr.message}` );
428
- }
429
-
430
- /**
431
- * Fill ratio of the unacked window: unacked / maxQueueSize, capped
432
- * at 1. Sync, O(1), allocation-free (ADR-018).
433
- * @returns {number}
434
- */
435
- const getPressure = function () {
436
- const ratio = state.unacked / maxQueueSize;
437
- return ratio > 1 ? 1 : ratio;
438
- };
439
-
440
- /**
441
- * Check and signal backpressure
442
- */
443
- const checkBackpressure = function () {
444
- const pressure = getPressure();
445
-
446
- if ( ( pressure > QUEUE_CRITICAL_THRESHOLD ) && onCritical ) {
447
- onCritical( 'QUEUE_CRITICAL', pressure );
448
- }
449
-
450
- if ( onBackpressure ) {
451
- onBackpressure( pressure );
452
- }
453
- };
454
-
455
- /**
456
- * Publish message immediately (fire-and-forget).
457
- *
458
- * Sync return per the ADR-018 sink contract:
459
- * - `{ ok: true }` on successful buffer.
460
- * - `{ ok: false, error: { code: 'STORAGE_FULL', message } }` if pre-flight
461
- * pressure check rejects (pressure >= STORAGE_PRESSURE_LIMIT).
462
- * - `{ ok: false, error: { code: 'SHUTTING_DOWN', message } }` if called
463
- * while shutdown is in progress.
464
- *
465
- * Async store-write or publish failures (after the message was buffered)
466
- * route through `onDeliveryFailure(err, { topic })` when supplied; without
467
- * a handler the adapter surfaces the failure via `Promise.reject`,
468
- * which Node logs as unhandledRejection (and v15+ exits on). Loud failure
469
- * beats silent loss.
470
- *
471
- * @param {string} topic - MQTT topic
472
- * @param {*} message - Message payload (will be encoded via config.codec)
473
- * @param {Object} [options] - Per-message options
474
- * @param {string} [options.type] - Message-type key for MESSAGE_EXPIRY lookup
475
- * @returns {{ok: true} | {ok: false, error: {code: string, message: string}}}
476
- */
477
- const publishNow = function ( topic, message, options ) {
478
- if ( state.shuttingDown ) {
479
- return ERR_SHUTTING_DOWN;
480
- }
481
-
482
- // Pre-flight pressure check — reject above STORAGE_PRESSURE_LIMIT to
483
- // leave headroom for in-flight drain and to surface backpressure to
484
- // producers before the store is literally full.
485
- if ( getPressure() >= STORAGE_PRESSURE_LIMIT ) {
486
- return ERR_STORAGE_FULL;
487
- }
488
-
489
- // Encode BEFORE the counter moves. A message the codec cannot
490
- // encode was never in flight, so it must not occupy a slot: a
491
- // leaked slot never drains, pressure ratchets up, and the
492
- // emitter ends up refusing everything. The
493
- // refusal is synchronous and classified; building it allocates,
494
- // which is fine on an error path this rare.
495
- let payload;
496
- try {
497
- payload = config.codec.pack( message );
498
- } catch ( packErr ) {
499
- state.stats.encodeErrors += 1;
500
- return {
501
- ok: false,
502
- error: {
503
- code: 'ENCODE_ERROR',
504
- message: `Message could not be encoded (topic=${topic}): ${packErr.message}`
505
- }
506
- };
507
- }
508
-
509
- const dedupId = crypto.randomUUID();
510
- const messageType = ( options && options.type ) || 'default';
511
- const expiry = MESSAGE_EXPIRY[ messageType ] || MESSAGE_EXPIRY.default;
512
-
513
- // Per-message allocations from here to the publish call (the
514
- // uuid, the properties object, the callback closure) cannot be
515
- // pre-allocated and reused: mqtt.js keeps the packet — properties
516
- // included — until PUBACK so it can retransmit, and a shared
517
- // mutated object would corrupt every queued retransmission (the
518
- // unavoidable-residual justification ADR-018 asks for).
519
- const properties = {
520
- messageExpiryInterval: expiry,
521
- userProperties: {
522
- [ WINK_NAMESPACE.dedupId ]: dedupId,
523
- [ WINK_NAMESPACE.timestamp ]: Date.now().toString(),
524
- [ WINK_NAMESPACE.version ]: '1.0'
525
- },
526
- contentType: config.codec.contentType
527
- };
528
-
529
- if ( config.codec.payloadFormatIndicator === 1 ) {
530
- properties.payloadFormatIndicator = true;
531
- }
532
-
533
- // Accept: the message is now in flight. The counter rises HERE,
534
- // synchronously with the accept decision, so the very next
535
- // publishNow call sees the true pressure (the old design's
536
- // optimistic-increment lesson, kept).
537
- state.unacked += 1;
538
-
539
- // Fire-and-forget publish with QoS 1. Outcome of this async callback is
540
- // observability-only — the publishNow caller has already received a
541
- // synchronous result. The callback fires exactly once per publish
542
- // (acknowledgment or failure), so the decrement below is the
543
- // counter's single exit — paired with the catch, which releases
544
- // the slot when the client rejects the call before accepting it.
545
- try {
546
- client.publish(
547
- topic,
548
- payload,
549
- { qos: QOS, properties },
550
- ( err ) => {
551
- state.unacked -= 1;
552
- if ( err ) {
553
- state.stats.publishErrors += 1;
554
- const deliveryErr = new Error(
555
- `winkComposer/mqttEmitter: publish failed (topic=${topic}): ${err.message || err.code || 'unknown'}`
556
- );
557
- deliveryErr.code = 'DELIVERY_FAILED';
558
- deliveryErr.cause = err;
559
- if ( onDeliveryFailure ) {
560
- onDeliveryFailure( deliveryErr, { topic } );
561
- } else {
562
- // No handler — surface as unhandledRejection so the
563
- // process logs loudly and (Node 15+) exits. Mirrors
564
- // QuestDB's persist-plan.js catch-and-throw: both
565
- // adapters route undeclared async failures
566
- // through the same Node-level escape hatch.
567
- Promise.reject( deliveryErr );
568
- }
569
- } else {
570
- state.stats.published += 1;
571
- }
572
- checkBackpressure();
573
- }
574
- );
575
- } catch ( publishErr ) {
576
- // The client threw before accepting the message; its callback
577
- // will never fire, so the slot is released here — the sync
578
- // face of DELIVERY_FAILED (see the header vocabulary).
579
- state.unacked -= 1;
580
- state.stats.publishErrors += 1;
581
- return {
582
- ok: false,
583
- error: {
584
- code: 'DELIVERY_FAILED',
585
- message: `MQTT publish rejected (topic=${topic}): ${publishErr.message}`
586
- }
587
- };
588
- }
589
-
590
- return RESULT_OK;
591
- };
592
-
593
- // Shutdown outcome, latched on the first call. A second
594
- // caller used to get an instant clean resolve — mid-drain, or even
595
- // after a shutdown that recorded dropped messages. Every caller now
596
- // receives the first call's promise.
597
- let shutdownPromise = null;
598
-
599
- const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000;
600
-
601
- /**
602
- * The real shutdown body. `shutdown` below latches its promise so
603
- * every caller receives this one outcome.
604
- *
605
- * **Drain-then-close** (a sustained-load finding, kept through the
606
- * ADR-021 rework): mqtt.js's own `end()` does not reliably drain
607
- * in-flight publishes, so the emitter waits on its own counter.
608
- *
609
- * 1. Sets `state.shuttingDown` so `publishNow` rejects new work
610
- * (returns `SHUTTING_DOWN` immediately).
611
- * 2. **Polls the unacked counter until zero**, using the whole
612
- * `timeout` budget — no early give-up. The wait runs whether or
613
- * not the connection is up at the moment of the call: mqtt.js
614
- * auto-reconnects, so a mid-drain reconnect can still deliver
615
- * the backlog inside the budget.
616
- * 3. Once drained (or budget expired), calls `client.end(...)`
617
- * with the remaining time as the broker-DISCONNECT budget.
618
- * 4. Force-closes if the broker hangs the DISCONNECT.
619
- *
620
- * The old design's shutdown re-drive (re-sending stragglers through
621
- * a store stream scan) died with the disk store: with the memory
622
- * store there is no store to scan, and a message whose PUBACK was
623
- * lost in-session is re-sent by the client itself on the next
624
- * reconnect. If it is still unacknowledged at the deadline, the
625
- * loss report counts it.
626
- *
627
- * A clean resolve is a delivery statement (ADR-018). When the
628
- * drain could not complete, shutdown rejects with classified
629
- * `SHUTDOWN_TIMEOUT` and `dropped: { count }` — the counter's exact
630
- * value — after the close has already happened (teardown first,
631
- * then the report). This fires CONNECTED OR NOT: with no disk
632
- * store, nothing survives the process, so a disconnected shutdown
633
- * with pending messages is a real loss and is reported as one. (The
634
- * wal-backed design resolved that case clean on purpose — its disk
635
- * store held the messages for the next session. ADR-021 traded that
636
- * away and says so.)
637
- */
638
- const doShutdown = async function ( timeout ) {
639
- state.shuttingDown = true;
640
-
641
- // A non-finite or non-positive timeout collapses the drain
642
- // (Infinity overflows setTimeout, which clamps it to ~1 ms;
643
- // NaN fails every deadline comparison), so it falls back to
644
- // the default budget.
645
- const budget = ( Number.isFinite( timeout ) && timeout > 0 ) ? timeout : DEFAULT_SHUTDOWN_TIMEOUT_MS;
646
- const deadline = Date.now() + budget;
647
- const drainPollIntervalMs = 25;
648
-
649
- // Drain wait — one cheap counter read per tick; sequential
650
- // awaits are the wait-for-condition pattern.
651
- while ( Date.now() < deadline && state.unacked > 0 ) {
652
- // eslint-disable-next-line no-await-in-loop
653
- await new Promise( ( r ) => setTimeout( r, drainPollIntervalMs ) );
654
- }
655
-
656
- // Whatever is still unacknowledged goes undelivered — publishNow
657
- // stopped accepting work at step 1, so the counter has settled
658
- // into its final value. It becomes the `dropped` payload below.
659
- const undeliveredCount = state.unacked;
660
- const remaining = Date.now() - deadline;
661
- const closeBudget = remaining < 0 ? Math.abs( remaining ) : 1;
662
-
663
- await new Promise( ( resolve ) => {
664
- const forceTimer = setTimeout( () => {
665
- client.end( true );
666
- resolve();
667
- }, closeBudget );
668
-
669
- if ( state.connected ) {
670
- client.end( false, {}, () => {
671
- clearTimeout( forceTimer );
672
- resolve();
673
- } );
674
- } else {
675
- clearTimeout( forceTimer );
676
- client.end( true );
677
- resolve();
678
- }
679
- } );
680
-
681
- // Teardown first, then the report: the close has already happened;
682
- // an incomplete drain must not read as a clean one (ADR-018).
683
- if ( undeliveredCount > 0 ) {
684
- const err = new Error(
685
- `winkComposer/mqttEmitter: shutdown closed with ${undeliveredCount} message(s) unacknowledged`
686
- );
687
- err.code = 'SHUTDOWN_TIMEOUT';
688
- err.dropped = { count: undeliveredCount };
689
- throw err;
690
- }
691
- return undefined;
692
- }; // doShutdown()
209
+ const client = openClient( resolved );
693
210
 
694
- /**
695
- * Graceful shutdown — drain semantics with a timeout floor.
696
- *
697
- * Accepts the ADR-018 shutdown-contract shape `{ timeout }` (defaults to
698
- * 5000ms). The destructure with `= {}` lets callers invoke
699
- * `shutdown()` with no argument, `shutdown( {} )`, or
700
- * `shutdown( { timeout: N } )`.
701
- *
702
- * The outcome is latched: the first call runs the shutdown, every
703
- * later call returns the same promise. A caller
704
- * arriving mid-drain waits for the real teardown; a caller arriving
705
- * after a lossy shutdown sees the same classified rejection, never
706
- * a contradicting clean resolve. One consequence: the first
707
- * caller's `{ timeout }` governs; a later caller's is ignored.
708
- *
709
- * @param {{timeout?: number}} [options]
710
- * @returns {Promise<void>}
711
- */
712
- const shutdown = function ( { timeout = DEFAULT_SHUTDOWN_TIMEOUT_MS } = {} ) {
713
- if ( !shutdownPromise ) {
714
- shutdownPromise = doShutdown( timeout );
715
- }
716
- return shutdownPromise;
717
- }; // shutdown()
718
-
719
- // Connection handlers - minimal, no automatic status
720
- client.on( 'connect', () => {
721
- state.connected = true;
722
- // mqtt.js fires 'connect' on every connack, including mid-run
723
- // reconnects. Counting the later ones gives operators — and the
724
- // release-soak signature policy — an observable "a reconnect
725
- // happened during this run" fact via getHealth().stats.
726
- if ( state.hasConnectedOnce ) {
727
- state.stats.reconnects += 1;
728
- }
729
- state.hasConnectedOnce = true;
730
- if ( config.debug ) {
731
- logger.info( `winkComposer/mqttEmitter: Connected to ${redactedBrokerUrl}` );
732
- }
211
+ const { getPressure, checkCritical, checkBackpressure, getHealth } = createHealth( {
212
+ state,
213
+ maxQueueSize: resolved.maxQueueSize,
214
+ onCritical: resolved.onCritical,
215
+ onBackpressure: resolved.onBackpressure
733
216
  } );
734
-
735
- client.on( 'offline', () => {
736
- state.connected = false;
737
- if ( config.debug ) {
738
- logger.info( `winkComposer/mqttEmitter: Offline - ${state.unacked} messages in flight` );
739
- }
217
+ const publishNow = createPublishNow( {
218
+ client,
219
+ state,
220
+ codec: resolved.codec,
221
+ getPressure,
222
+ checkCritical,
223
+ checkBackpressure,
224
+ onDeliveryFailure: resolved.onDeliveryFailure
740
225
  } );
741
-
742
- client.on( 'error', ( err ) => {
743
- state.stats.errors += 1;
744
- if ( config.debug ) {
745
- logger.error( `winkComposer/mqttEmitter: client error: ${err.message}` );
746
- }
226
+ const { flush, shutdown } = createShutdown( { client, state } );
227
+
228
+ // The permanent handlers attach before the grace wait's one-shot
229
+ // listener, so a handle resolved via connack already reports
230
+ // `connected: true`.
231
+ attachConnectionHandlers( {
232
+ client,
233
+ state,
234
+ debug: resolved.debug,
235
+ redactedBrokerUrl: resolved.redactedBrokerUrl
747
236
  } );
748
237
 
749
- /**
750
- * Health snapshot for operator monitoring (uniform across sinks).
751
- *
752
- * Returns the ADR-018 health floor `{status, connected, pressure}` plus
753
- * this adapter's own `stats` addition. `stats.unacked` is the live
754
- * counter — the number of messages
755
- * accepted but not yet acknowledged by the broker; exactly what a
756
- * process crash at this instant would cost (ADR-021).
757
- *
758
- * Status derivation (uniform with Terminal/QuestDB; see file header):
759
- * - `red` when `!connected`
760
- * - `yellow` when connected AND `pressure >= HEALTH_PRESSURE_YELLOW_THRESHOLD`
761
- * - `green` otherwise
762
- *
763
- * The wal-backed design's `storeHealth`, `circuitState`, and `metrics`
764
- * diagnostics died with the LevelDB store (ADR-021).
765
- *
766
- * @returns {Object} Health snapshot — required floor + live counter
767
- */
768
- const getHealth = function () {
769
- const pressure = getPressure();
770
-
771
- let status;
772
- if ( !state.connected ) {
773
- status = 'red';
774
- } else if ( pressure >= HEALTH_PRESSURE_YELLOW_THRESHOLD ) {
775
- status = 'yellow';
776
- } else {
777
- status = 'green';
778
- }
779
-
780
- return {
781
- // Required floor (ADR-018)
782
- status,
783
- connected: state.connected,
784
- pressure,
785
- // This adapter's addition beyond the floor
786
- stats: { ...state.stats, unacked: state.unacked }
787
- };
788
- };
789
-
790
238
  const handle = {
791
239
  publishNow,
240
+ flush,
792
241
  getHealth,
793
242
  shutdown,
794
243
  getPressure
795
244
  };
796
245
 
797
- // First-connack grace (bounded). With a grace, the return is a
798
- // Promise the wiring layer awaits (ADR-018 allows sync or async
799
- // factories): by the time wire() hands the emitter to the flow,
800
- // the client has either seen its first connack or spent the
801
- // budget. Expiry is not an error — the posture stays 'recovering'.
802
- // With connectGraceMs 0 the handle returns synchronously, exactly
803
- // the pre-grace behavior. The permanent 'connect' handler above is
804
- // attached before the wait's one-shot listener, so a handle
805
- // resolved via connack already reports `connected: true`.
806
- if ( connectGraceMs === 0 ) {
807
- return handle;
246
+ // First-connack grace (bounded). By the time wire() hands the
247
+ // emitter to the flow, the client has either seen its first
248
+ // connack or spent the budget. Expiry is not an error — the
249
+ // posture stays 'recovering'. With connectGraceMs 0 no wait is
250
+ // armed and the promise resolves at once.
251
+ if ( resolved.connectGraceMs > 0 ) {
252
+ await waitForFirstConnect( client, resolved.connectGraceMs );
808
253
  }
809
- return waitForFirstConnect( client, connectGraceMs ).then( () => handle );
810
- }; // createMQTTEmitter()
254
+ return handle;
255
+ }; // createEmitter()