@winkjs/composer 0.6.0 → 0.7.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 +1 -1
  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 +99 -13
  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 +574 -847
  24. package/src/core/storage-manager/questdb/persist-plan.js +128 -167
  25. package/src/core/storage-manager/questdb/resolve-options.js +403 -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
@@ -0,0 +1,151 @@
1
+ // core/emitter-manager/mqtt/health.js
2
+
3
+ /**
4
+ * @fileoverview Pressure and health of the MQTT emitter (ADR-018 §8,
5
+ * ADR-021).
6
+ *
7
+ * One counter, `state.unacked`, feeds everything here. `getPressure()`
8
+ * is that counter over the cap, and `getHealth()` derives the status
9
+ * from the link and the pressure. The factory builds this once;
10
+ * nothing allocates on the pressure read.
11
+ *
12
+ * Two pressure callbacks live here, with different cadences.
13
+ * `onBackpressure` is a level signal: `checkBackpressure()` calls it
14
+ * after every acknowledgment with the current pressure. `onCritical`
15
+ * is an edge signal: `checkCritical()` calls it once when an accepted
16
+ * publish lifts pressure above the critical threshold, then disarms
17
+ * it. An acknowledgment that brings pressure below the yellow
18
+ * threshold re-arms it. Pressure rises only on accepts, so the
19
+ * crossing is detected on the accept path. During a broker outage no
20
+ * acknowledgment arrives, and an ack-side check would stay silent
21
+ * through the whole climb. The accept-path cost is one boolean read
22
+ * and, while armed, one comparison. A level signal here would repeat
23
+ * the same warning on every acknowledgment while pressure stays high,
24
+ * and the message-level callback guard prints each call it catches.
25
+ *
26
+ * Health-status semantics (uniform across sinks):
27
+ * - `red` if `!state.connected`.
28
+ * - `yellow` if connected AND `pressure >= 0.66` (threshold shared with
29
+ * ADR-020's Draft yield proposal).
30
+ * - `green` otherwise.
31
+ *
32
+ * @see ADR-018
33
+ * @see ADR-021
34
+ */
35
+
36
+ import { QUEUE_CRITICAL_THRESHOLD } from './constants.js';
37
+
38
+ /**
39
+ * Pressure threshold above which `getHealth().status` elevates to at least
40
+ * 'yellow' (uniform with Terminal/QuestDB). The value matches the
41
+ * pressure-aware-yield design (ADR-020, still a Draft). That design
42
+ * proposes 0.66 as the pressure level where the flow would start yielding
43
+ * to let sinks drain; a benchmark still has to confirm the number. Today
44
+ * the yield trigger is time-only (ADR-024), so this alignment is
45
+ * forward-looking.
46
+ * @type {number}
47
+ */
48
+ const HEALTH_PRESSURE_YELLOW_THRESHOLD = 0.66;
49
+
50
+ /**
51
+ * Builds the pressure and health readers over the shared state.
52
+ *
53
+ * @param {Object} deps
54
+ * @param {Object} deps.state - The emitter's core state
55
+ * @param {number} deps.maxQueueSize - The unacked cap
56
+ * @param {function|null} deps.onCritical - Guard-wrapped high-pressure callback, or null
57
+ * @param {function|null} deps.onBackpressure - Guard-wrapped pressure callback, or null
58
+ * @returns {{getPressure: function, checkCritical: function, checkBackpressure: function, getHealth: function}}
59
+ */
60
+ export const createHealth = function ( { state, maxQueueSize, onCritical, onBackpressure } ) {
61
+ // The edge state of `onCritical`. Armed means the next climb past
62
+ // the critical threshold fires the callback. It starts armed.
63
+ let criticalArmed = true;
64
+
65
+ /**
66
+ * Fill ratio of the unacked window: unacked / maxQueueSize, capped
67
+ * at 1. Sync, O(1), allocation-free (ADR-018).
68
+ * @returns {number}
69
+ */
70
+ const getPressure = function () {
71
+ const ratio = state.unacked / maxQueueSize;
72
+ return ratio > 1 ? 1 : ratio;
73
+ };
74
+
75
+ /**
76
+ * Fires `onCritical` once when an accepted publish lifts pressure
77
+ * above the critical threshold, then disarms it. Runs on the
78
+ * accept path, after the counter rises. The disarm comes before
79
+ * the call, so a callback that throws (contained by the guard)
80
+ * still counts as fired.
81
+ */
82
+ const checkCritical = function () {
83
+ if ( criticalArmed && onCritical ) {
84
+ const pressure = getPressure();
85
+ if ( pressure > QUEUE_CRITICAL_THRESHOLD ) {
86
+ criticalArmed = false;
87
+ onCritical( 'QUEUE_CRITICAL', pressure );
88
+ }
89
+ }
90
+ };
91
+
92
+ /**
93
+ * Runs after every acknowledgment. Re-arms `onCritical` once
94
+ * pressure is back below the yellow threshold, and reports the
95
+ * current pressure to `onBackpressure`.
96
+ */
97
+ const checkBackpressure = function () {
98
+ const pressure = getPressure();
99
+
100
+ if ( pressure < HEALTH_PRESSURE_YELLOW_THRESHOLD ) {
101
+ criticalArmed = true;
102
+ }
103
+
104
+ if ( onBackpressure ) {
105
+ onBackpressure( pressure );
106
+ }
107
+ };
108
+
109
+ /**
110
+ * Health snapshot for operator monitoring (uniform across sinks).
111
+ *
112
+ * Returns the ADR-018 health floor `{status, connected, pressure}` plus
113
+ * this adapter's own `stats` addition. `stats.unacked` is the live
114
+ * counter — the number of messages
115
+ * accepted but not yet acknowledged by the broker; exactly what a
116
+ * process crash at this instant would cost (ADR-021).
117
+ *
118
+ * Status derivation (uniform with Terminal/QuestDB; see file header):
119
+ * - `red` when `!connected`
120
+ * - `yellow` when connected AND `pressure >= HEALTH_PRESSURE_YELLOW_THRESHOLD`
121
+ * - `green` otherwise
122
+ *
123
+ * The wal-backed design's `storeHealth`, `circuitState`, and `metrics`
124
+ * diagnostics died with the LevelDB store (ADR-021).
125
+ *
126
+ * @returns {Object} Health snapshot — required floor + live counter
127
+ */
128
+ const getHealth = function () {
129
+ const pressure = getPressure();
130
+
131
+ let status;
132
+ if ( !state.connected ) {
133
+ status = 'red';
134
+ } else if ( pressure >= HEALTH_PRESSURE_YELLOW_THRESHOLD ) {
135
+ status = 'yellow';
136
+ } else {
137
+ status = 'green';
138
+ }
139
+
140
+ return {
141
+ // Required floor (ADR-018)
142
+ status,
143
+ connected: state.connected,
144
+ pressure,
145
+ // This adapter's addition beyond the floor
146
+ stats: { ...state.stats, unacked: state.unacked }
147
+ };
148
+ };
149
+
150
+ return { getPressure, checkCritical, checkBackpressure, getHealth };
151
+ }; // createHealth()
@@ -36,10 +36,55 @@
36
36
  * `id`, `configSchema`, `createEmitter`, `durabilityClass`, and the
37
37
  * default aggregate referencing the same constants.
38
38
  *
39
+ * Shed policy and outage budget. Once the unacknowledged count reaches
40
+ * 90% of `maxQueueSize`, `publishNow` refuses new messages with
41
+ * `STORAGE_FULL`, and the messages already accepted stay safe. With
42
+ * the default cap of 10,000, refusal starts at 9,000 messages. That
43
+ * is 15 minutes of outage at 10 messages a second, and 2.5 hours at
44
+ * one a second. Memory is the count times the payload size: about
45
+ * 10 MB at 1 KB a message.
46
+ *
47
+ * Ordering. While connected, messages reach the broker in publish
48
+ * order over the one connection. After a reconnect the client replays
49
+ * its in-memory store in insertion order, and new publishes wait in
50
+ * the client's processing queue until that replay ends (mqtt.js
51
+ * 5.15.1, `client.js:1230-1262`). Delivery is at-least-once, so a
52
+ * consumer can see a message twice. Every message carries a
53
+ * `winkDedupId`, and the MQTT source drops the repeats (ADR-022).
54
+ *
55
+ * Edge-baseline defaults (every one runs within a 2 GB device):
56
+ *
57
+ * | Setting | Default | Environment variable |
58
+ * |------------------|----------|---------------------------|
59
+ * | `maxQueueSize` | 10000 | `MQTT_MAX_QUEUE_SIZE` |
60
+ * | `connectGraceMs` | 500 | `MQTT_CONNECT_GRACE_MS` |
61
+ * | message expiry | 3600 s | `MQTT_MSG_EXPIRY` |
62
+ * | keepalive | 60 s | `MQTT_KEEPALIVE` |
63
+ * | reconnect period | 5000 ms | `MQTT_RECONNECT_MS` |
64
+ * | connect timeout | 30000 ms | `MQTT_CONNECT_TIMEOUT_MS` |
65
+ *
66
+ * `brokerUrl` falls back to `MQTT_BROKER_URL`. The reconnect period
67
+ * gains a random share of up to 20% per client (`utils/jitter`).
68
+ * The cap is clamped to 60,000, the packet-id ceiling.
69
+ *
70
+ * Related decisions beyond ADR-018 and ADR-021. ADR-022: this adapter
71
+ * writes the `winkDedupId` the source dedups on. ADR-023: `publishNow`
72
+ * reads the message during the call and keeps no reference. ADR-027:
73
+ * the three callbacks run through the shared guard. ADR-028: every
74
+ * line and error follows the message grammar. ADR-026 (Proposed) is
75
+ * the planned TLS surface for `mqtts://`.
76
+ *
39
77
  * Directory structure:
40
78
  * core/emitter-manager/mqtt/
41
79
  * ├── index.js # Module surface: id, schema, factory
42
- * ├── emitter.js # The emitter implementation
80
+ * ├── emitter.js # The factory: resolve, open, assemble
81
+ * ├── resolve-config.js # Validation in one fixed order; the
82
+ * │ # three callbacks armed by the guard
83
+ * ├── connection.js # Client open, first-connack wait, the
84
+ * │ # connection event handlers
85
+ * ├── health.js # getPressure, getHealth, backpressure
86
+ * ├── publish.js # publishNow, the hot path
87
+ * ├── shutdown-drain.js # The latched drain-then-close shutdown
43
88
  * ├── mqtt-store.js # DORMANT LevelDB store (ADR-021; kept
44
89
  * │ # for the WAL successor, not wired,
45
90
  * │ # not shipped in the npm package)
@@ -47,7 +92,9 @@
47
92
  * └── test/ # Spec files
48
93
  *
49
94
  * @example
50
- * const emitter = createEmitter({
95
+ * // The factory is async, as QuestDB's is. A configuration error
96
+ * // rejects the promise with a classified `INVALID_CONFIG` error.
97
+ * const emitter = await createEmitter({
51
98
  * brokerUrl: 'mqtt://broker.local:1883',
52
99
  * codec: jsonCodec
53
100
  * });
@@ -59,8 +106,24 @@
59
106
  */
60
107
 
61
108
  import { validators } from '../../utils/validate/index.js';
109
+ import { classifyAddress } from '../../utils/address/index.js';
62
110
  import { createEmitter } from './emitter.js';
63
111
 
112
+ /**
113
+ * Schema validator for `brokerUrl`: non-empty and never `localhost`
114
+ * (ADR-030). A value the URL grammar cannot read passes; the MQTT
115
+ * library reports its own error for it.
116
+ *
117
+ * @param {*} value - The configured value
118
+ * @returns {boolean} Whether the value is allowed
119
+ */
120
+ const isAllowedBrokerUrl = function ( value ) {
121
+ if ( !validators.nonEmptyString( value ) ) {
122
+ return false;
123
+ }
124
+ return classifyAddress( value, 'url' ).kind !== 'localhost';
125
+ }; // isAllowedBrokerUrl()
126
+
64
127
  /**
65
128
  * Emitter identifier - must match target in emitIf specs.
66
129
  * @type {string}
@@ -106,8 +169,9 @@ export const configSchema = {
106
169
  brokerUrl: {
107
170
  type: 'string',
108
171
  required: false,
109
- minLength: 1,
110
- error: 'brokerUrl must be a non-empty string (e.g., mqtt://broker.local:1883)'
172
+ validator: isAllowedBrokerUrl,
173
+ error: 'brokerUrl must be a non-empty broker URL with a literal address or a name, never ' +
174
+ 'localhost; e.g., mqtt://127.0.0.1:1883'
111
175
  },
112
176
  codec: {
113
177
  type: 'object',
@@ -0,0 +1,292 @@
1
+ // core/emitter-manager/mqtt/publish.js
2
+
3
+ /**
4
+ * @fileoverview The MQTT emitter's hot path: `publishNow` (ADR-013,
5
+ * ADR-018, ADR-021).
6
+ *
7
+ * A publish is fire-and-forget with QoS 1. The call encodes the
8
+ * message, checks pressure, raises the unacked counter, and hands the
9
+ * packet to mqtt.js. It returns a synchronous `{ ok }` result and
10
+ * never a promise. The client's callback fires once per packet, on
11
+ * acknowledgment or failure, and `settlePublish` lowers the counter
12
+ * there. So the counter's single exit is that callback, paired with
13
+ * the catch that releases the slot when the client rejects the call
14
+ * before accepting it.
15
+ *
16
+ * Refusals are classified, synchronous, and allocation-free where the
17
+ * result can be shared: `SHUTTING_DOWN` and `STORAGE_FULL` are
18
+ * singletons. `ENCODE_ERROR` and the sync face of `DELIVERY_FAILED`
19
+ * build their result, because they carry the failure's own text and
20
+ * are rare.
21
+ *
22
+ * @see ADR-013
23
+ * @see ADR-018
24
+ * @see ADR-021
25
+ */
26
+
27
+ import crypto from 'crypto';
28
+ import {
29
+ QOS,
30
+ WINK_NAMESPACE,
31
+ MESSAGE_EXPIRY
32
+ } from './constants.js';
33
+
34
+ /**
35
+ * Pre-flight reject threshold on store pressure.
36
+ * 0.9 = 10% headroom for in-flight drain on shutdown, plus a clean band
37
+ * between the yellow-health threshold (0.66, shared with the yield level the
38
+ * ADR-020 Draft proposes) and store exhaustion (1.0). At or above this,
39
+ * `publishNow` returns `STORAGE_FULL` synchronously; the message is not buffered.
40
+ * @type {number}
41
+ */
42
+ const STORAGE_PRESSURE_LIMIT = 0.9;
43
+
44
+ /**
45
+ * Singleton success result reused on every successful publish. Hot-path zero
46
+ * allocation per ADR-013 / ADR-004. Plain literal — not frozen.
47
+ * @type {{ok: true}}
48
+ */
49
+ const RESULT_OK = { ok: true };
50
+
51
+ /**
52
+ * Singleton error result for the pre-flight pressure-limit reject. Reused on
53
+ * every occurrence; rare in healthy operation (pressure this close to the
54
+ * limit means delivery has stopped draining), but a defined contract path.
55
+ * @type {{ok: false, error: {code: string, message: string}}}
56
+ */
57
+ const ERR_STORAGE_FULL = {
58
+ ok: false,
59
+ error: {
60
+ code: 'STORAGE_FULL',
61
+ message: 'Store at or above pressure limit (0.9) — cannot accept message'
62
+ }
63
+ };
64
+
65
+ /**
66
+ * Singleton error result for publishes attempted while shutdown is in progress.
67
+ * @type {{ok: false, error: {code: string, message: string}}}
68
+ */
69
+ const ERR_SHUTTING_DOWN = {
70
+ ok: false,
71
+ error: {
72
+ code: 'SHUTTING_DOWN',
73
+ message: 'Emitter is shutting down — message dropped'
74
+ }
75
+ };
76
+
77
+ /**
78
+ * The `ENCODE_ERROR` refusal. Built per event, because it carries the
79
+ * codec's own message; the path is rare.
80
+ *
81
+ * @param {string} topic - The topic of the refused message
82
+ * @param {Error} packErr - The codec's throw
83
+ * @returns {{ok: false, error: {code: string, message: string}}}
84
+ */
85
+ const encodeRefusal = function ( topic, packErr ) {
86
+ return {
87
+ ok: false,
88
+ error: {
89
+ code: 'ENCODE_ERROR',
90
+ message: `Message could not be encoded (topic=${topic}): ${packErr.message}`
91
+ }
92
+ };
93
+ }; // encodeRefusal()
94
+
95
+ /**
96
+ * The sync face of `DELIVERY_FAILED`: the client threw before
97
+ * accepting the message, so nothing is in flight.
98
+ *
99
+ * @param {string} topic - The topic of the refused message
100
+ * @param {Error} publishErr - The client's throw
101
+ * @returns {{ok: false, error: {code: string, message: string}}}
102
+ */
103
+ const publishRefusal = function ( topic, publishErr ) {
104
+ return {
105
+ ok: false,
106
+ error: {
107
+ code: 'DELIVERY_FAILED',
108
+ message: `MQTT publish rejected (topic=${topic}): ${publishErr.message}`
109
+ }
110
+ };
111
+ }; // publishRefusal()
112
+
113
+ /**
114
+ * Builds the MQTT v5 properties of one publish: the expiry, the
115
+ * dedup id, the timestamp, and the content type.
116
+ *
117
+ * Per-message allocations here (the uuid, the properties object) and
118
+ * the callback closure in `publishNow` cannot be pre-allocated and
119
+ * reused: mqtt.js keeps the packet — properties included — until
120
+ * PUBACK so it can retransmit, and a shared mutated object would
121
+ * corrupt every queued retransmission (the unavoidable-residual
122
+ * justification ADR-018 asks for). The options object that carries
123
+ * `properties` into the client is not on this list; the client
124
+ * copies it at once, so one object per emitter serves every publish.
125
+ *
126
+ * @param {Object} codec - The configured codec (contentType, payloadFormatIndicator)
127
+ * @param {number} expiry - The message expiry in seconds
128
+ * @returns {Object} the properties object mqtt.js keeps until PUBACK
129
+ */
130
+ const buildProperties = function ( codec, expiry ) {
131
+ const properties = {
132
+ messageExpiryInterval: expiry,
133
+ userProperties: {
134
+ [ WINK_NAMESPACE.dedupId ]: crypto.randomUUID(),
135
+ [ WINK_NAMESPACE.timestamp ]: Date.now().toString(),
136
+ [ WINK_NAMESPACE.version ]: '1.0'
137
+ },
138
+ contentType: codec.contentType
139
+ };
140
+
141
+ if ( codec.payloadFormatIndicator === 1 ) {
142
+ properties.payloadFormatIndicator = true;
143
+ }
144
+ return properties;
145
+ }; // buildProperties()
146
+
147
+ /**
148
+ * Builds `publishNow` over the shared state and the armed callbacks.
149
+ *
150
+ * @param {Object} deps
151
+ * @param {Object} deps.client - mqtt.js client
152
+ * @param {Object} deps.state - The emitter's core state
153
+ * @param {Object} deps.codec - The configured codec (pack, contentType)
154
+ * @param {function} deps.getPressure - The pressure reader from health.js
155
+ * @param {function} deps.checkCritical - The accept-path edge check from health.js
156
+ * @param {function} deps.checkBackpressure - The ack-path pressure signaller from health.js
157
+ * @param {function|null} deps.onDeliveryFailure - Guard-wrapped delivery-failure callback, or null
158
+ * @returns {function} publishNow
159
+ */
160
+ export const createPublishNow = function ( {
161
+ client, state, codec, getPressure, checkCritical, checkBackpressure, onDeliveryFailure
162
+ } ) {
163
+ // One options object per emitter, reused on every publish. mqtt.js
164
+ // copies its options synchronously before any other work and keeps
165
+ // only `properties` in the packet (5.15.1, `client.js:376-381`),
166
+ // so the shared object is never retained. `properties` is assigned
167
+ // per call; it is the object the client keeps until PUBACK.
168
+ const publishOptions = { qos: QOS, properties: null };
169
+
170
+ /**
171
+ * Settles one publish when its callback fires. Outcome of this async
172
+ * callback is observability-only — the publishNow caller has
173
+ * already received a synchronous result. The callback fires exactly
174
+ * once per publish (acknowledgment or failure), so the decrement
175
+ * here is the counter's single exit.
176
+ *
177
+ * @param {Error|null} err - The client's failure, or null on acknowledgment
178
+ * @param {string} topic - The published topic, for the failure report
179
+ */
180
+ const settlePublish = function ( err, topic ) {
181
+ state.unacked -= 1;
182
+ if ( err ) {
183
+ state.stats.publishErrors += 1;
184
+ const deliveryErr = new Error(
185
+ `winkComposer/mqttEmitter: publish failed (topic=${topic}): ${err.message || err.code || 'unknown'}`
186
+ );
187
+ deliveryErr.code = 'DELIVERY_FAILED';
188
+ deliveryErr.cause = err;
189
+ if ( onDeliveryFailure ) {
190
+ onDeliveryFailure( deliveryErr, { topic } );
191
+ } else {
192
+ // No handler — surface as unhandledRejection so the
193
+ // process logs loudly and (Node 15+) exits. Mirrors
194
+ // QuestDB's persist-plan.js catch-and-throw: both
195
+ // adapters route undeclared async failures
196
+ // through the same Node-level escape hatch.
197
+ Promise.reject( deliveryErr );
198
+ }
199
+ } else {
200
+ state.stats.published += 1;
201
+ }
202
+ checkBackpressure();
203
+ }; // settlePublish()
204
+
205
+ /**
206
+ * Publish message immediately (fire-and-forget).
207
+ *
208
+ * Sync return per the ADR-018 sink contract:
209
+ * - `{ ok: true }` on successful buffer.
210
+ * - `{ ok: false, error: { code: 'STORAGE_FULL', message } }` if pre-flight
211
+ * pressure check rejects (pressure >= STORAGE_PRESSURE_LIMIT).
212
+ * - `{ ok: false, error: { code: 'SHUTTING_DOWN', message } }` if called
213
+ * while shutdown is in progress.
214
+ *
215
+ * Async store-write or publish failures (after the message was buffered)
216
+ * route through `onDeliveryFailure(err, { topic })` when supplied; without
217
+ * a handler the adapter surfaces the failure via `Promise.reject`,
218
+ * which Node logs as unhandledRejection (and v15+ exits on). Loud failure
219
+ * beats silent loss.
220
+ *
221
+ * @param {string} topic - MQTT topic
222
+ * @param {*} message - Message payload (will be encoded via config.codec)
223
+ * @param {Object} [options] - Per-message options
224
+ * @param {string} [options.type] - Message-type key for MESSAGE_EXPIRY lookup
225
+ * @returns {{ok: true} | {ok: false, error: {code: string, message: string}}}
226
+ */
227
+ const publishNow = function ( topic, message, options ) {
228
+ if ( state.shuttingDown ) {
229
+ return ERR_SHUTTING_DOWN;
230
+ }
231
+
232
+ // Pre-flight pressure check — reject above STORAGE_PRESSURE_LIMIT to
233
+ // leave headroom for in-flight drain and to surface backpressure to
234
+ // producers before the store is literally full.
235
+ if ( getPressure() >= STORAGE_PRESSURE_LIMIT ) {
236
+ return ERR_STORAGE_FULL;
237
+ }
238
+
239
+ // Encode BEFORE the counter moves. A message the codec cannot
240
+ // encode was never in flight, so it must not occupy a slot. A
241
+ // leaked slot never drains, pressure only rises, and the
242
+ // emitter ends up refusing everything. The refusal is
243
+ // synchronous and classified. Building it allocates, which is
244
+ // fine on an error path this rare.
245
+ let payload;
246
+ try {
247
+ payload = codec.pack( message );
248
+ } catch ( packErr ) {
249
+ state.stats.encodeErrors += 1;
250
+ return encodeRefusal( topic, packErr );
251
+ }
252
+
253
+ const messageType = ( options && options.type ) || 'default';
254
+ const expiry = MESSAGE_EXPIRY[ messageType ] || MESSAGE_EXPIRY.default;
255
+ publishOptions.properties = buildProperties( codec, expiry );
256
+
257
+ // Accept: the message is now in flight. The counter rises HERE,
258
+ // synchronously with the accept decision, so the very next
259
+ // publishNow call sees the true pressure (the old design's
260
+ // optimistic-increment lesson, kept).
261
+ state.unacked += 1;
262
+
263
+ // Fire-and-forget publish with QoS 1. The callback settles the
264
+ // counter (see settlePublish above), paired with the catch,
265
+ // which releases the slot when the client rejects the call
266
+ // before accepting it.
267
+ try {
268
+ client.publish(
269
+ topic,
270
+ payload,
271
+ publishOptions,
272
+ ( err ) => settlePublish( err, topic )
273
+ );
274
+ } catch ( publishErr ) {
275
+ // The client threw before accepting the message; its callback
276
+ // will never fire, so the slot is released here — the sync
277
+ // face of DELIVERY_FAILED (see the header vocabulary).
278
+ state.unacked -= 1;
279
+ state.stats.publishErrors += 1;
280
+ return publishRefusal( topic, publishErr );
281
+ }
282
+
283
+ // The message is accepted and the counter has its new value.
284
+ // Pressure rises only here, so this is where a climb past the
285
+ // critical threshold is seen (see health.js).
286
+ checkCritical();
287
+
288
+ return RESULT_OK;
289
+ }; // publishNow()
290
+
291
+ return publishNow;
292
+ }; // createPublishNow()