@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
package/README.md CHANGED
@@ -43,7 +43,7 @@ Requires Node.js 22 or newer.
43
43
  The Quick Start flow replays a CSV feed of pump motor temperatures. It cleans each reading, flags a hot motor, confirms the heat is not a blip, and prints an alert. One command scaffolds it as a runnable project:
44
44
 
45
45
  ```bash
46
- npm create @winkjs/composer
46
+ npm create @winkjs/composer@latest
47
47
  ```
48
48
 
49
49
  Accept the directory prompt, then run the three printed steps. A run takes about eight seconds. Watch for the `overheat` alert. Prefer zero install? [Build this flow interactively](https://composer.winkjs.org/docs/playground/hello-flow) in the browser playground. The same project also lives in the repository, in [`examples/hello-flow`](examples/hello-flow).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@winkjs/composer",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Composable Streaming Intelligence — turn streaming data into actionable insights, from edge to cloud",
5
5
  "keywords": [
6
6
  "streaming",
@@ -70,7 +70,7 @@
70
70
  "sinon": "^19.0.2"
71
71
  },
72
72
  "dependencies": {
73
- "@questdb/nodejs-client": "^4.2.0",
73
+ "@questdb/nodejs-client": "~4.2.0",
74
74
  "glob": "^13.0.6",
75
75
  "mqtt": "^5.15.1",
76
76
  "msgpackr": "^1.11.10",
package/src/composer.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * .sanitize(...)
14
14
  * .threshold(...)
15
15
  * .source(csv, { path: './data.csv' })
16
- * .emitter(mqttEmitter, { brokerUrl: 'mqtt://localhost' })
16
+ * .emitter(mqttEmitter, { brokerUrl: 'mqtt://127.0.0.1' })
17
17
  * .run();
18
18
  */
19
19
 
@@ -0,0 +1,209 @@
1
+ // core/emitter-manager/mqtt/connection.js
2
+
3
+ /**
4
+ * @fileoverview The MQTT emitter's connection: opening the client,
5
+ * the bounded first-connack wait, and the event handlers that keep
6
+ * `state.connected` and the counters current.
7
+ *
8
+ * Reconnection stays the library's job (ADR-018). mqtt.js retries at
9
+ * its configured period for as long as the process lives. This module
10
+ * only listens: `connect` marks the link up and counts every connack
11
+ * after the first as a reconnect, `offline` marks it down, and
12
+ * `error` counts the event. Nothing here touches the message path.
13
+ *
14
+ * What the operator reads. Two layers of lines print through the
15
+ * logger facade, with or without `debug`, because a broker outage
16
+ * must reach a production log (ADR-018, the two-party rule).
17
+ *
18
+ * Edge lines, token `DELIVERY_HEALTH`, print once per change of link
19
+ * state. Going offline prints at error with the in-flight count. The
20
+ * next connack prints at warn with the outage length and the count.
21
+ * Nothing prints while a state persists, however long the outage
22
+ * lasts. The library fires `offline` once per outage, and a repeat
23
+ * would print nothing, because the state did not change. A client
24
+ * that never connected prints the same offline line on its first
25
+ * failure, because the change of state is the same.
26
+ *
27
+ * Attempt lines, token `CONNECT_FAILED`, print at warn from the
28
+ * client's `error` event. That event names the reason an edge line
29
+ * cannot: a refused connect, a connack timeout, or a keepalive
30
+ * timeout just before the link drops. The shared line bound keeps
31
+ * two per episode in full, then one summary a minute, so a night of
32
+ * retries every five seconds costs one line a minute, not one every
33
+ * five seconds. A stream
34
+ * error without a `code` never reaches this handler; the library
35
+ * swallows it (mqtt.js 5.15.1, `client.js:294-305`).
36
+ *
37
+ * The three `debug` lines stay beside these, at info and error.
38
+ *
39
+ * The startup posture is recovering (ADR-018). `openClient` never
40
+ * fails because the broker is unreachable. Only a URL the library
41
+ * itself refuses is a setup failure, classified `INVALID_CONFIG`.
42
+ *
43
+ * @see ADR-018
44
+ * @see ADR-028
45
+ * @see ADR-030
46
+ */
47
+
48
+ import { logger } from '../../logger/index.js';
49
+ import { nameWarningMessage } from '../../utils/address/index.js';
50
+ import { createLineBound } from '../../utils/line-rate/index.js';
51
+ import { monotonicNow } from '../../utils/clock/index.js';
52
+ import { invalidConfig } from './resolve-config.js';
53
+
54
+ /** How many failed attempts per episode print in full. */
55
+ const FULL_ATTEMPT_LINES_PER_EPISODE = 2;
56
+
57
+ /** The attempt summary interval, also the quiet gap that ends an episode. */
58
+ const ATTEMPT_SUMMARY_INTERVAL_MS = 60000;
59
+
60
+ /**
61
+ * Prints the one ADDRESS_IS_NAME line when the broker host is a name.
62
+ *
63
+ * @param {Object} address - The classified broker address
64
+ */
65
+ const warnIfBrokerIsName = function ( address ) {
66
+ if ( address.kind === 'name' ) {
67
+ logger.warn(
68
+ `winkComposer/mqttEmitter: ${nameWarningMessage( { field: 'brokerUrl', host: address.host } )}`
69
+ );
70
+ }
71
+ }; // warnIfBrokerIsName()
72
+
73
+ /**
74
+ * Creates the MQTT client. mqtt.connect throws its own raw TypeError
75
+ * on a malformed url; classify it like every other setup failure. The
76
+ * error text uses the redacted url — a malformed url can still carry
77
+ * credentials. The name warning goes first, so it prints before any
78
+ * socket opens (ADR-030).
79
+ *
80
+ * @param {Object} resolved - The resolved configuration
81
+ * @param {string} resolved.brokerUrl - The broker URL
82
+ * @param {Object} resolved.brokerAddress - Its classified address
83
+ * @param {string} resolved.redactedBrokerUrl - The URL with credentials hidden
84
+ * @param {Object} resolved.mqttOptions - The connect options
85
+ * @param {function} resolved.connect - `mqtt.connect` or the injected connect function
86
+ * @returns {Object} the mqtt.js client
87
+ * @throws {TypeError} INVALID_CONFIG when the client refuses the URL
88
+ */
89
+ export const openClient = function ( { brokerUrl, brokerAddress, redactedBrokerUrl, mqttOptions, connect } ) {
90
+ warnIfBrokerIsName( brokerAddress );
91
+ try {
92
+ return connect( brokerUrl, mqttOptions );
93
+ } catch ( connectErr ) {
94
+ throw invalidConfig( `brokerUrl rejected by the MQTT client ('${redactedBrokerUrl}'): ${connectErr.message}` );
95
+ }
96
+ }; // openClient()
97
+
98
+ /**
99
+ * Bounded wait for the client's first 'connect' event (the broker's
100
+ * connection acknowledgment). Resolves when the event fires or when
101
+ * graceMs elapses, whichever comes first. Expiry is NOT an error —
102
+ * the startup posture stays 'recovering' (ADR-018): the caller gets
103
+ * a working handle that reports `connected: false` while mqtt.js
104
+ * retries in the background. The one-shot listener is removed on
105
+ * expiry so a late connack reaches only the permanent state handler;
106
+ * the timer is cleared on connect so nothing pins the event loop.
107
+ *
108
+ * @param {Object} client - mqtt.js client (EventEmitter surface)
109
+ * @param {number} graceMs - positive wait budget, milliseconds
110
+ * @returns {Promise<void>} resolves on first connect or expiry; never rejects
111
+ */
112
+ export const waitForFirstConnect = function ( client, graceMs ) {
113
+ return new Promise( ( resolve ) => {
114
+ let timer = null;
115
+ const onConnect = function () {
116
+ clearTimeout( timer );
117
+ resolve();
118
+ };
119
+ timer = setTimeout( () => {
120
+ client.removeListener( 'connect', onConnect );
121
+ resolve();
122
+ }, graceMs );
123
+ client.once( 'connect', onConnect );
124
+ } );
125
+ }; // waitForFirstConnect()
126
+
127
+ /**
128
+ * Builds the bounded attempt line for one emitter.
129
+ *
130
+ * @returns {Function} `( message ) => void`, the line bound over the failure text
131
+ */
132
+ const createAttemptLine = function () {
133
+ return createLineBound( {
134
+ fullLines: FULL_ATTEMPT_LINES_PER_EPISODE,
135
+ intervalMs: ATTEMPT_SUMMARY_INTERVAL_MS,
136
+ printFull: function ( message ) {
137
+ logger.warn( `winkComposer/mqttEmitter: connection attempt failed [CONNECT_FAILED]: ${message}` );
138
+ },
139
+ printSummary: function ( count, seconds, message ) {
140
+ logger.warn(
141
+ `winkComposer/mqttEmitter: connection attempt failed [CONNECT_FAILED]: ${message}; ${count} more attempt(s) failed in the last ${seconds} s`
142
+ );
143
+ }
144
+ } );
145
+ }; // createAttemptLine()
146
+
147
+ /**
148
+ * Attaches the permanent connection handlers. No automatic status:
149
+ * the handle's `getHealth()` reads `state`. The edge lines and the
150
+ * bounded attempt line print from here (see the file header).
151
+ *
152
+ * @param {Object} deps
153
+ * @param {Object} deps.client - mqtt.js client
154
+ * @param {Object} deps.state - The emitter's core state
155
+ * @param {boolean} deps.debug - Whether the debug lines print
156
+ * @param {string} deps.redactedBrokerUrl - The URL with credentials hidden
157
+ */
158
+ export const attachConnectionHandlers = function ( { client, state, debug, redactedBrokerUrl } ) {
159
+ // The link edge. `linkDown` is true from an offline event until
160
+ // the next connack; `downSince` is when that outage began on the
161
+ // stopwatch, for the restored line, so a step in the wall clock
162
+ // cannot change the length it names (ADR-018).
163
+ let linkDown = false;
164
+ let downSince = 0;
165
+ const attemptFailed = createAttemptLine();
166
+
167
+ client.on( 'connect', () => {
168
+ state.connected = true;
169
+ // mqtt.js fires 'connect' on every connack, including mid-run
170
+ // reconnects. Counting the later ones gives operators — and the
171
+ // release-soak signature policy — an observable "a reconnect
172
+ // happened during this run" fact via getHealth().stats.
173
+ if ( state.hasConnectedOnce ) {
174
+ state.stats.reconnects += 1;
175
+ }
176
+ state.hasConnectedOnce = true;
177
+ if ( linkDown ) {
178
+ linkDown = false;
179
+ const seconds = Math.round( ( monotonicNow() - downSince ) / 1000 );
180
+ logger.warn(
181
+ `winkComposer/mqttEmitter: broker connection restored after ${seconds} s, ${state.unacked} message(s) in flight [DELIVERY_HEALTH]`
182
+ );
183
+ }
184
+ if ( debug ) {
185
+ logger.info( `winkComposer/mqttEmitter: Connected to ${redactedBrokerUrl}` );
186
+ }
187
+ } );
188
+
189
+ client.on( 'offline', () => {
190
+ state.connected = false;
191
+ if ( debug ) {
192
+ logger.info( `winkComposer/mqttEmitter: Offline - ${state.unacked} messages in flight` );
193
+ }
194
+ if ( linkDown ) {
195
+ return;
196
+ }
197
+ linkDown = true;
198
+ downSince = monotonicNow();
199
+ logger.error( `winkComposer/mqttEmitter: broker offline, ${state.unacked} message(s) in flight [DELIVERY_HEALTH]` );
200
+ } );
201
+
202
+ client.on( 'error', ( err ) => {
203
+ state.stats.errors += 1;
204
+ attemptFailed( err.message );
205
+ if ( debug ) {
206
+ logger.error( `winkComposer/mqttEmitter: client error: ${err.message}` );
207
+ }
208
+ } );
209
+ }; // attachConnectionHandlers()
@@ -49,53 +49,67 @@ export const DEFAULT_MAX_QUEUE_BYTES = 52428800; // 50 MB
49
49
  // Operational constant — not configurable (design decision)
50
50
  export const QUEUE_CRITICAL_THRESHOLD = 0.8;
51
51
 
52
- // Hard ceiling on maxQueueSize — not configurable (design
53
- // decision). MQTT packet ids are 16-bit, so at most 65,535 publishes
54
- // can be unacknowledged at once; a queue sized beyond that promises
55
- // capacity the protocol cannot deliver. The measured failure mode with
56
- // the default id allocator: the id space wraps and a new packet
57
- // OVERWRITES an unacked packet's store entry, destroying the only copy
58
- // of an undelivered QoS-1 message (about 1 per 700,000 under sustained
59
- // load in the soak gate). The emitter uses the client's
60
- // UniqueMessageIdProvider, which never reissues an in-use id — so the
61
- // unacked window must never hold more messages than the id space has
62
- // ids. 60,000 leaves margin for ids stuck on never-acknowledged packets.
52
+ /**
53
+ * Hard ceiling on maxQueueSize, not configurable (design decision).
54
+ * MQTT packet ids are 16-bit, so at most 65,535 publishes can be
55
+ * unacknowledged at once. A queue sized beyond that promises capacity
56
+ * the protocol cannot deliver. With the default id allocator the id
57
+ * space wraps, and a new packet OVERWRITES an unacknowledged packet's
58
+ * store entry. That destroys the only copy of an undelivered QoS-1
59
+ * message, measured at about 1 per 700,000 under sustained load in
60
+ * the soak gate.
61
+ *
62
+ * The emitter uses the client's UniqueMessageIdProvider, which never
63
+ * reissues an id that is in use. So the unacked window must never
64
+ * hold more messages than the id space has ids. 60,000 leaves margin
65
+ * for ids stuck on packets that are never acknowledged.
66
+ * @type {number}
67
+ */
63
68
  export const MQTT_INFLIGHT_ID_LIMIT = 60000;
64
69
 
65
- // Message expiry intervals (seconds)
66
- export const MESSAGE_EXPIRY = {
70
+ // Message expiry intervals (seconds), indexed by the caller's
71
+ // `options.type`. A dictionary indexed by an external key has no
72
+ // prototype, so a key such as 'constructor' finds nothing and falls
73
+ // back to the default. With a plain object that key resolved to a
74
+ // function. mqtt-packet then destroyed the connection with an error
75
+ // that mqtt.js swallows (the emitter header, design decision 3).
76
+ export const MESSAGE_EXPIRY = Object.assign( Object.create( null ), {
67
77
  telemetry: 3600, // 1 hour - sensor data loses value quickly
68
78
  status: 86400, // 24 hours - status needs longer persistence
69
79
  default: ENV_VARS.mqttMsgExpiry // Default expiry from ENV_VARS
70
- };
80
+ } );
71
81
 
72
- // MQTT v5 configuration
82
+ // MQTT v5 configuration. The reconnect period here is the floor;
83
+ // resolve-config.js adds the per-client jitter when it builds the
84
+ // connect options.
73
85
  export const MQTT_CONFIG = {
74
86
  protocolVersion: MQTT_PROTOCOL_V,
75
87
  keepalive: ENV_VARS.mqttKeepalive,
76
88
  reconnectPeriod: ENV_VARS.mqttReconnectMs,
77
89
  connectTimeout: ENV_VARS.mqttConnectTimeoutMs,
90
+
78
91
  // A deliberately clean session, spelled with the key mqtt.js
79
- // actually reads (`clean`). The MQTT 5 spelling `cleanStart` is
80
- // silently ignored by mqtt.js — the source carried that exact
81
- // defect until 2026-07-09, caught by the ADR-022 dedup soak
82
- // (see the cleanStart→clean mapping in source-manager/mqtt/client.js).
83
- //
92
+ // actually reads (`clean`). mqtt.js silently ignores the MQTT 5
93
+ // spelling `cleanStart`. The source carried that exact defect
94
+ // until 2026-07-09, when the ADR-022 dedup soak caught it (see
95
+ // the cleanStart→clean mapping in source-manager/mqtt/client.js).
96
+
84
97
  // Why clean: the emitter is publish-only. It holds no
85
- // subscriptions a broker session could preserve, and its
98
+ // subscriptions a broker session could preserve. Its
86
99
  // unacknowledged messages live in the mqtt.js client's in-memory
87
100
  // store inside this process, tracked by composer's unacked
88
- // counter (ADR-021).
89
- // A broker session buys it nothing. Before this fix, the ignored
90
- // key plus a random per-start clientId parked a 7-day orphan
91
- // session at the broker on every restart (`sessionExpiryInterval`
92
- // was riding along here as the same dead weight — the source is
93
- // the only adapter that needs MQTT_SESSION_EXPIRY_S).
101
+ // counter (ADR-021). A broker session buys it nothing.
102
+
103
+ // Before this fix, the ignored key plus a random per-start
104
+ // clientId parked a 7-day orphan session at the broker on every
105
+ // restart. `sessionExpiryInterval` rode along here as the same
106
+ // dead weight. The source is the only adapter that needs
107
+ // MQTT_SESSION_EXPIRY_S.
94
108
  clean: true
95
109
  };
96
110
 
97
- // Storage configuration — operational constant for the DORMANT LevelDB
98
- // store (ADR-021; kept for the WAL successor, not wired)
111
+ // Storage configuration — an operational constant for the DORMANT
112
+ // LevelDB store. Kept for the WAL successor, not wired (ADR-021).
99
113
  export const STORE_CONFIG = {
100
114
  compression: true,
101
115
  cacheSize: 8 * 1024 * 1024 // 8MB LevelDB cache