@winkjs/composer 0.5.1 → 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 (125) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/composer.js +1 -1
  4. package/src/core/batch/aoo-encoder.js +4 -4
  5. package/src/core/batch/sliding-window.js +2 -2
  6. package/src/core/codec/index.js +4 -4
  7. package/src/core/emitter-manager/mqtt/connection.js +209 -0
  8. package/src/core/emitter-manager/mqtt/constants.js +43 -29
  9. package/src/core/emitter-manager/mqtt/emitter.js +108 -633
  10. package/src/core/emitter-manager/mqtt/health.js +151 -0
  11. package/src/core/emitter-manager/mqtt/index.js +68 -4
  12. package/src/core/emitter-manager/mqtt/publish.js +292 -0
  13. package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
  14. package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
  15. package/src/core/emitter-manager/terminal/assert-columns.js +1 -1
  16. package/src/core/emitter-manager/terminal/index.js +20 -5
  17. package/src/core/env-vars.js +143 -16
  18. package/src/core/logger/index.js +174 -0
  19. package/src/core/mqtt-protocol.js +2 -2
  20. package/src/core/partition-manager/get-stats.js +31 -0
  21. package/src/core/partition-manager/index.js +1 -0
  22. package/src/core/partition-manager/init.js +15 -0
  23. package/src/core/partition-manager/update.js +143 -87
  24. package/src/core/partition-manager/validate-flow.js +1 -1
  25. package/src/core/semantics/loader.js +1 -1
  26. package/src/core/semantics/warnings.js +5 -3
  27. package/src/core/shutdown-manager/index.js +71 -21
  28. package/src/core/source-manager/csv/start.js +109 -35
  29. package/src/core/source-manager/mqtt/client.js +276 -83
  30. package/src/core/source-manager/mqtt/dedup.js +19 -14
  31. package/src/core/source-manager/mqtt/index.js +33 -6
  32. package/src/core/source-manager/mqtt/status.js +211 -46
  33. package/src/core/source-manager/record-shape.js +55 -0
  34. package/src/core/source-manager/test-harness/field-generator.js +1 -1
  35. package/src/core/source-manager/test-harness/start.js +52 -10
  36. package/src/core/source-manager/test-harness/validate.js +1 -1
  37. package/src/core/storage-manager/questdb/address-policy.js +264 -0
  38. package/src/core/storage-manager/questdb/assert-columns.js +2 -2
  39. package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
  40. package/src/core/storage-manager/questdb/ensure-tables.js +1 -1
  41. package/src/core/storage-manager/questdb/flush-engine.js +852 -0
  42. package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
  43. package/src/core/storage-manager/questdb/index.js +592 -827
  44. package/src/core/storage-manager/questdb/persist-plan.js +147 -162
  45. package/src/core/storage-manager/questdb/resolve-options.js +403 -0
  46. package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
  47. package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
  48. package/src/core/test-utils/tcp-proxy.js +194 -1
  49. package/src/core/tunable/helpers.js +1 -1
  50. package/src/core/utils/address/index.js +362 -0
  51. package/src/core/utils/address/probe.js +210 -0
  52. package/src/core/utils/callback-guard/index.js +176 -0
  53. package/src/core/utils/clock/index.js +34 -0
  54. package/src/core/utils/half-life/index.js +4 -4
  55. package/src/core/utils/jitter/index.js +51 -0
  56. package/src/core/utils/line-rate/index.js +104 -0
  57. package/src/core/utils/quantize/index.js +43 -19
  58. package/src/core/utils/validate/composer-validators.js +2 -2
  59. package/src/core/utils/validate/index.js +1 -1
  60. package/src/core/utils/validate/validate.js +1 -1
  61. package/src/core/wiring/assert-handle.js +5 -5
  62. package/src/core/wiring/assert-module.js +2 -2
  63. package/src/core/wiring/wire-emitters.js +7 -4
  64. package/src/core/wiring/wire-linear-graph.js +1 -1
  65. package/src/core/wiring/wire-node.js +6 -6
  66. package/src/core/wiring/wire-semantics.js +1 -1
  67. package/src/core/wiring/wire-storages.js +14 -5
  68. package/src/flow/driver.js +31 -6
  69. package/src/flow/expand-single-input-array.js +4 -4
  70. package/src/flow/flow-api-schemas.js +4 -4
  71. package/src/flow/flow.js +62 -41
  72. package/src/flow/for-each.js +12 -11
  73. package/src/flow/get-target-array.js +2 -2
  74. package/src/flow/load-node-module.js +1 -1
  75. package/src/flow/make-node-method.js +4 -4
  76. package/src/flow/resolve-pick-by-field.js +1 -1
  77. package/src/flow/run.js +200 -40
  78. package/src/flow/serialize.js +1 -1
  79. package/src/flow/spec-builder.js +1 -1
  80. package/src/flow/validate.js +1 -1
  81. package/src/nodes/butterworth-filter/init.js +5 -4
  82. package/src/nodes/categorize/update.js +3 -1
  83. package/src/nodes/controller/update.js +2 -1
  84. package/src/nodes/dwell-time-tracker/update.js +2 -1
  85. package/src/nodes/emit-if/helpers.js +57 -11
  86. package/src/nodes/emit-if/init.js +1 -1
  87. package/src/nodes/emit-if/update.js +8 -11
  88. package/src/nodes/page-hinkley/update.js +2 -1
  89. package/src/nodes/pass-if/update.js +3 -1
  90. package/src/nodes/persist-if/helpers.js +60 -4
  91. package/src/nodes/persist-if/init.js +1 -1
  92. package/src/nodes/persist-if/update.js +8 -16
  93. package/src/nodes/persistence-check/update.js +2 -1
  94. package/src/nodes/sanitize/update.js +4 -2
  95. package/src/nodes/swing-watch/update.js +3 -2
  96. package/src/nodes/threshold/update.js +2 -1
  97. package/src/nodes/trend/compute-confidence.js +4 -4
  98. package/src/nodes/trend/update.js +2 -1
  99. package/src/nodes/winnow/update.js +3 -1
  100. package/src/tools/training/features.js +13 -13
  101. package/src/tools/training/logistic.js +1 -1
  102. package/src/tools/training/metrics.js +2 -2
  103. package/src/tools/training/scale.js +2 -2
  104. package/src/tools/training/validate.js +7 -7
  105. package/src/core/utils/console-formatter/index.js +0 -207
  106. package/src/nodes/archive/csv/index.js +0 -6
  107. package/src/nodes/archive/csv/init.js +0 -36
  108. package/src/nodes/archive/csv/load.js +0 -70
  109. package/src/nodes/archive/csv/reset.js +0 -14
  110. package/src/nodes/archive/csv/simple-parser.js +0 -216
  111. package/src/nodes/archive/csv/test.js +0 -173
  112. package/src/nodes/archive/csv/update.js +0 -63
  113. package/src/nodes/sse-emitter/index.js +0 -7
  114. package/src/nodes/sse-emitter/init.js +0 -105
  115. package/src/nodes/sse-emitter/introspect.js +0 -134
  116. package/src/nodes/sse-emitter/publish-to.js +0 -6
  117. package/src/nodes/sse-emitter/recompute.js +0 -16
  118. package/src/nodes/sse-emitter/reset.js +0 -19
  119. package/src/nodes/sse-emitter/update.js +0 -69
  120. package/src/nodes/sse-emitter/utils/broadcast-heartbeat.js +0 -36
  121. package/src/nodes/sse-emitter/utils/build-message.js +0 -31
  122. package/src/nodes/sse-emitter/utils/connection-manager.js +0 -45
  123. package/src/nodes/sse-emitter/utils/endpoint-initializer.js +0 -20
  124. package/src/nodes/sse-emitter/utils/health-monitor.js +0 -38
  125. package/src/nodes/sse-emitter/utils/qos-handler.js +0 -41
@@ -38,19 +38,28 @@
38
38
  * strict mode (an `onWarning` that throws) rejects a bad row with the sender
39
39
  * untouched.
40
40
  *
41
- * Async-flush failures — the no-silent-failures contract:
42
- * - @questdb/nodejs-client v4 declares sender.at() as async; the buffer
43
- * mutation is sync but the trailing `await this.tryFlush()` can fire a
44
- * network flush. When that flush fails (HTTP timeout, buffer overflow,
45
- * QDB unreachable), the rows in that batch are dropped. Per composer's
46
- * "no silent failures" contract, those drops MUST surface loudly.
41
+ * Row append rejections — the no-silent-failures contract:
42
+ * - @questdb/nodejs-client v4 declares sender.at() as async. The append
43
+ * runs first and synchronously; the trailing `await this.tryFlush()`
44
+ * does nothing, because the adapter turns the client's own flush
45
+ * trigger off (ADR-029). So the promise rejects only when the append
46
+ * itself threw, which for a validated row means the client's byte
47
+ * ceiling (`max_buf_size`). The row never completed. Per composer's
48
+ * "no silent failures" contract, that loss MUST surface.
47
49
  *
48
- * Failures are routed through the `onDeliveryFailure` callback. When
49
- * a caller provides one, they own the response (log, retry, alert,
50
- * stop the flow). When none is provided, the catch handler **throws**
51
- * the failure so it surfaces as an unhandled rejection and the
52
- * process crashes loudly — better than running on with silently
53
- * missing data.
50
+ * The rejection is routed through the `onDeliveryFailure` callback,
51
+ * with a context of the same shape as a flush report:
52
+ * `{ trigger: 'append', rowsLost: 1, abandoned: false, probe: null,
53
+ * tableName }`. The context is one object per insight type, shared
54
+ * by every rejection, so a handler must not keep it.
55
+ * When a caller provides one, they own the response (log, alert,
56
+ * stop the flow). When none is provided, one classified
57
+ * `DELIVERY_FAILED` console line reports it and the process keeps
58
+ * running: an unattended deployment must report a lost row, not
59
+ * stop on it. One handler exists per insight type and is attached
60
+ * to each row's promise. The derived promise that `.catch()` creates
61
+ * is the one per-row allocation this file cannot avoid while `at()`
62
+ * is async.
54
63
  *
55
64
  * This separates two concerns:
56
65
  * - `onWarning` — soft, per-row data quality (NaN in float column,
@@ -62,8 +71,13 @@
62
71
  * configuration, not in the data, so every row would repeat it.
63
72
  * The field is ignored either way; the column stores the
64
73
  * partition id.
65
- * - `onDeliveryFailure` — hard, batch-level data loss. Default
66
- * behaviour: throw, so production cannot run with silent drops.
74
+ * The wording of the lines, the default handler, and the bound
75
+ * on that default live in `skip-warnings.js`. The default is
76
+ * bounded per column (ADR-029): two full lines per episode,
77
+ * then one summary a minute. A user `onWarning` hears every
78
+ * skip, and a throwing one is strict mode (ADR-027).
79
+ * - `onDeliveryFailure` — hard data loss: the row the client
80
+ * refused. Default behaviour: one DELIVERY_FAILED console line.
67
81
  *
68
82
  * `err.code` (setup-time throws per ADR-018 fail-fast setup):
69
83
  * - `INVALID_CONFIG` — buildPersistPlans called with non-function
@@ -72,9 +86,9 @@
72
86
  * name through a throwaway client buffer — a name is not a value, so the
73
87
  * per-message phase 1 below cannot catch it).
74
88
  *
75
- * `err.code` (runtime throws / unhandled rejections):
76
- * - `DELIVERY_FAILED` — sender.at() async flush rejected and no
77
- * `onDeliveryFailure` callback was provided.
89
+ * Runtime console classification:
90
+ * - `DELIVERY_FAILED` — sender.at() rejected and no `onDeliveryFailure`
91
+ * callback was provided. One `logger.error` line naming the table.
78
92
  *
79
93
  * @see docs/architecture/storage-layer.md
80
94
  * @see ADR-018
@@ -83,33 +97,20 @@
83
97
  import { SenderBufferV1 } from '@questdb/nodejs-client';
84
98
 
85
99
  import { QUEST_WRITERS, writeAsString, createFloat64Writer } from './writers.js';
86
-
87
- // ============================================================================
88
- // DEFAULT WARNING HANDLER
89
- // ============================================================================
100
+ import { defaultOnWarning, makeColumnWarning, makeRowWarning } from './skip-warnings.js';
101
+ import { wrapCallback } from '../../utils/callback-guard/index.js';
102
+ import { logger } from '../../logger/index.js';
90
103
 
91
104
  /**
92
- * Default warning handler for invalid column values.
93
- * Logs to console in WinkComposer format.
94
- *
95
- * Validation behavior:
96
- * - null/undefined columns: skip column only (QuestDB stores NULL)
97
- * - NaN/Infinity in numeric columns: skip column only (QuestDB stores NULL)
98
- * - Wrong-typed column values (e.g. a number where the column is string-typed):
99
- * skip column only, never coerce — the warning names the expected and
100
- * received types
101
- * - Invalid designatedTimestamp: skip entire row
102
- *
103
- * For strict mode (throw on any invalid), provide:
104
- * { onWarning: (msg) => { throw new Error(msg); } }
105
- *
106
- * Future extension: return value may control skip-row vs skip-column behavior.
107
- *
108
- * @param {string} message - Warning message describing the issue
105
+ * Console channel for the callback guard: one classified line in this
106
+ * adapter's family. Receives an already-safe detail string, never the
107
+ * raw thrown value.
109
108
  */
110
- const defaultOnWarning = function ( message ) {
111
- console.warn( `WinkComposer/questdb: ${message}` );
112
- };
109
+ const reportCallbackFault = function ( severity, name, detail ) {
110
+ logger.error(
111
+ `winkComposer/questdb: user callback ${name} failed [CALLBACK_FAILED]: ${detail}`
112
+ );
113
+ }; // reportCallbackFault()
113
114
 
114
115
  /**
115
116
  * Check if column type requires numeric validation (NaN/Infinity check).
@@ -153,29 +154,6 @@ ACCEPTS.bool = ( value ) => typeof value === 'boolean';
153
154
  */
154
155
  const acceptAny = () => true;
155
156
 
156
- /**
157
- * Builds the reason text for a value that failed its phase-1 acceptance
158
- * check. Called only on the rare skip path, so its allocations are
159
- * acceptable (same budget as the error returns in index.js).
160
- *
161
- * @param {*} rawValue - the rejected value
162
- * @param {boolean} isNumeric - whether the column type is numeric
163
- * @param {string} expectedType - declared column type, named in the message
164
- * @returns {string} plain reason text for the skip warning
165
- */
166
- const skipReason = function ( rawValue, isNumeric, expectedType ) {
167
- if ( rawValue === null ) return 'null';
168
- if ( rawValue === undefined ) return 'undefined';
169
- if ( isNumeric && typeof rawValue === 'number' ) {
170
- if ( Number.isNaN( rawValue ) ) return 'NaN';
171
- // A finite number can only be rejected by the integer-required
172
- // types (int64/timestamp/designated timestamp) — float64 accepts
173
- // every finite number.
174
- return Number.isFinite( rawValue ) ? 'non-integer' : 'non-finite';
175
- }
176
- return `wrong-typed (expected ${expectedType}, received ${typeof rawValue})`;
177
- }; // skipReason()
178
-
179
157
  /**
180
158
  * Asserts every ILP name a plan will write — the table name and each column
181
159
  * name — by driving them once through a throwaway client buffer, so the
@@ -213,7 +191,7 @@ const assertIlpNames = function ( tableName, columnNames ) {
213
191
  }
214
192
  } catch ( err ) {
215
193
  const wrapped = new Error(
216
- `WinkComposer/questdb: invalid ILP ${kind} '${current}' — ${err.message}`
194
+ `winkComposer/questdb: invalid ILP ${kind} '${current}' — ${err.message}`
217
195
  );
218
196
  wrapped.code = 'INVALID_CONFIG';
219
197
  wrapped.cause = err;
@@ -251,11 +229,11 @@ const assertIlpNames = function ( tableName, columnNames ) {
251
229
  * @param {string} tablePrefix - Prefix for table names (typically assetClass.name)
252
230
  * @param {Object} [options] - Optional configuration
253
231
  * @param {function} [options.onWarning] - Soft per-row warning callback for
254
- * invalid values (NaN, null, invalid timestamp). Default: console.warn.
255
- * @param {function} [options.onDeliveryFailure] - Hard batch-level failure
256
- * callback for `sender.at()` async flush rejection (HTTP timeout, buffer
257
- * overflow, QDB unreachable). Default: throw — surfaces as an unhandled
258
- * rejection so production cannot run with silent row drops.
232
+ * invalid values (NaN, null, invalid timestamp). Default: one
233
+ * `logger.warn` line per skip, bounded per column (see the file header).
234
+ * @param {function} [options.onDeliveryFailure] - Called as
235
+ * `( err, { tableName } )` when `sender.at()` rejects (the client's
236
+ * byte ceiling). Default: one classified DELIVERY_FAILED console line.
259
237
  * @returns {Object.<string, function>} Map of insightType to persistRow function
260
238
  */
261
239
  const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
@@ -264,18 +242,32 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
264
242
 
265
243
  if ( providedOnWarning !== undefined && typeof providedOnWarning !== 'function' ) {
266
244
  // Per ADR-018, setup-time throws carry classified err.code.
267
- const err = new Error( 'WinkComposer/questdb: onWarning must be a function' );
245
+ const err = new Error( 'winkComposer/questdb: onWarning must be a function' );
268
246
  err.code = 'INVALID_CONFIG';
269
247
  throw err;
270
248
  }
271
249
  if ( providedOnDeliveryFailure !== undefined && typeof providedOnDeliveryFailure !== 'function' ) {
272
- const err = new Error( 'WinkComposer/questdb: onDeliveryFailure must be a function' );
250
+ const err = new Error( 'winkComposer/questdb: onDeliveryFailure must be a function' );
273
251
  err.code = 'INVALID_CONFIG';
274
252
  throw err;
275
253
  }
276
254
 
255
+ // onWarning stays raw on purpose. A strict-mode onWarning throws,
256
+ // and that throw is the instruction that rejects the row. ADR-027
257
+ // keeps such callbacks — ones whose throw or return the adapter
258
+ // acts on — out of the guard's scope. The skip sites reach it
259
+ // through the pre-built warnings (`skip-warnings.js`); this raw
260
+ // handler serves the once-per-type mismatch warning below.
277
261
  const onWarning = providedOnWarning || defaultOnWarning;
278
- const onDeliveryFailure = providedOnDeliveryFailure || null;
262
+ //
263
+ // onDeliveryFailure only notifies, so the shared guard arms it. It
264
+ // was validated raw above and is wrapped once here (ADR-018: a
265
+ // broken handler costs its own output, never the flush chain).
266
+ // Absent stays null, so the no-handler DELIVERY_FAILED escape
267
+ // hatch below keeps its exact meaning.
268
+ const onDeliveryFailure = wrapCallback( providedOnDeliveryFailure || null, {
269
+ name: 'onDeliveryFailure', severity: 'red', report: reportCallbackFault
270
+ } );
279
271
  const plansByInsightType = Object.create( null );
280
272
  const insightTypes = assetClass.insightTypes || Object.create( null );
281
273
  const columns = assetClass.columns;
@@ -292,13 +284,15 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
292
284
  // column itself, from the partition id. A persisted column with
293
285
  // the same name would make the CREATE TABLE ask for 'assetId'
294
286
  // twice. That happens whether the name appears in the columns
295
- // list or as the designated timestamp. Fail fast here and name
296
- // the fix; otherwise QuestDB answers at table creation with its
297
- // raw "Duplicate column" error. A dictionary column named
298
- // 'assetId' that no insightType persists stays legal.
287
+ // list or as the designated timestamp.
288
+ //
289
+ // Fail fast here and name the fix. Otherwise QuestDB answers at
290
+ // table creation with its raw "Duplicate column" error. A
291
+ // dictionary column named 'assetId' that no insightType
292
+ // persists stays legal.
299
293
  if ( persistedColumnNames.includes( 'assetId' ) || designatedTimestamp === 'assetId' ) {
300
294
  const err = new Error(
301
- `WinkComposer/questdb: insightType '${insightTypeName}' uses reserved column name 'assetId' — ` +
295
+ `winkComposer/questdb: insightType '${insightTypeName}' uses reserved column name 'assetId' — ` +
302
296
  'composer writes this column automatically from the flow\'s .assetId() field (the partition id); ' +
303
297
  'rename the semantics column or remove it from this insightType'
304
298
  );
@@ -306,31 +300,34 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
306
300
  throw err;
307
301
  }
308
302
 
309
- // Pre-compile writer steps
310
- // designatedTimestamp is handled separately via sender.at()
311
- // Other columns use their type-specific writers
303
+ // Pre-compile the writer steps. The designated timestamp is
304
+ // handled separately, via sender.at(). Every other column uses
305
+ // its type-specific writer.
312
306
  const stepNames = [];
313
307
  const stepWriters = [];
314
- const stepIsNumeric = [];
315
- const stepTypes = [];
316
308
  const stepAccepts = [];
309
+ // One skip warning per column, pre-built here so the per-row
310
+ // path only calls it (see the file header for the bound).
311
+ const stepWarnings = [];
317
312
 
318
313
  for ( let j = 0; j < persistedColumnNames.length; j += 1 ) {
319
314
  const columnName = persistedColumnNames[ j ];
320
315
 
321
- // Skip designatedTimestamp - handled separately via sender.at()
316
+ // Skip the designated timestamp. It is handled separately, via
317
+ // sender.at().
322
318
  if ( columnName !== designatedTimestamp ) {
323
319
  const columnSpec = columns[ columnName ];
324
320
  const columnType = columnSpec ? columnSpec.type : 'string';
325
321
 
326
322
  stepNames.push( columnName );
327
- stepIsNumeric.push( isNumericType( columnType ) );
328
- stepTypes.push( columnType );
329
323
  stepAccepts.push( ACCEPTS[ columnType ] || acceptAny );
324
+ stepWarnings.push( makeColumnWarning(
325
+ providedOnWarning, insightTypeName, columnName, isNumericType( columnType ), columnType
326
+ ) );
330
327
 
331
- // float64 columns use resolution-aware writer factory
332
- // Note: columnSpec is guaranteed to exist here since columnType='float64'
333
- // requires columnSpec.type to be 'float64' (line 65)
328
+ // float64 columns use the resolution-aware writer factory.
329
+ // columnSpec exists here: columnType is 'float64' only when
330
+ // columnSpec.type is 'float64'.
334
331
  if ( columnType === 'float64' ) {
335
332
  stepWriters.push( createFloat64Writer( columnSpec.resolution ) );
336
333
  } else {
@@ -341,6 +338,28 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
341
338
 
342
339
  const stepCount = stepNames.length;
343
340
  const tableName = tablePrefix + '_' + insightTypeName;
341
+ const warnRowSkipped = makeRowWarning( providedOnWarning, insightTypeName, designatedTimestamp );
342
+
343
+ // One rejection handler and one shared context per insight
344
+ // type, built here so the per-row path allocates nothing. See
345
+ // the file header for the context's shape and what a rejection
346
+ // means.
347
+ const appendContext = {
348
+ trigger: 'append',
349
+ rowsLost: 1,
350
+ abandoned: false,
351
+ probe: null,
352
+ tableName
353
+ };
354
+ const onAppendRejected = function ( err ) {
355
+ if ( onDeliveryFailure ) {
356
+ onDeliveryFailure( err, appendContext );
357
+ return;
358
+ }
359
+ logger.error(
360
+ `winkComposer/questdb: row append failed for table '${tableName}' [DELIVERY_FAILED]: ${err.message}`
361
+ );
362
+ }; // onAppendRejected()
344
363
  // Fail-fast at startup: a bad table or column name would otherwise
345
364
  // throw inside the client mid-row at write time.
346
365
  assertIlpNames( tableName, stepNames );
@@ -354,36 +373,31 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
354
373
  // One report carries all the information.
355
374
  let assetIdMismatchWarned = false;
356
375
 
357
- // Create closure that captures pre-resolved references
358
- // Interface: persistRow(sender, message, partitionId) -> boolean
359
- // Returns true when a row was opened and completed on the sender,
360
- // false when phase 1 skipped the whole row (the sender was never
361
- // touched). The caller's buffered-row accounting keys off this —
362
- // a skipped row must not count as buffered.
363
- // Note: partitionId is internal name, written as 'assetId' column to QuestDB
376
+ // Create the closure that captures the pre-resolved references.
377
+ // Interface: persistRow(sender, message, partitionId) -> boolean.
378
+ // It returns true when a row was opened and completed on the
379
+ // sender. It returns false when phase 1 skipped the whole row,
380
+ // so the sender was never touched.
381
+ //
382
+ // The caller's buffered-row accounting keys off that return. A
383
+ // skipped row must not count as buffered. Note that partitionId
384
+ // is the internal name. It is written as the 'assetId' column
385
+ // in QuestDB.
364
386
  plansByInsightType[ insightTypeName ] = function ( sender, message, partitionId ) {
365
387
  // ---- Phase 1: validate. No sender calls — nothing irreversible
366
388
  // happens until every value has been checked (see file header).
367
389
 
368
- // designatedTimestamp first - if invalid, skip entire row
390
+ // The designated timestamp first. When it is missing or not
391
+ // an integer, skip the entire row. Integer or bigint matches
392
+ // the client's own .at() validation. Probe-verified: at(
393
+ // ...000.5, 'ms' ) throws "Designated timestamp must be an
394
+ // integer or BigInt". It throws AFTER the whole row is
395
+ // written, so catching it here is the only place the row
396
+ // survives intact. A null or undefined value fails the same
397
+ // check, and the warning names which it was.
369
398
  const tsValue = message[ designatedTimestamp ];
370
- if ( tsValue === undefined || tsValue === null ) {
371
- onWarning(
372
- `designatedTimestamp '${designatedTimestamp}' is ${tsValue === null ? 'null' : 'undefined'} ` +
373
- `in insightType '${insightTypeName}' (asset: ${partitionId}) - row skipped`
374
- );
375
- return false;
376
- }
377
- // Integer-or-bigint, matching the client's own .at() validation
378
- // (probe-verified: at( ...000.5, 'ms' ) throws "Designated
379
- // timestamp must be an integer or BigInt" — and it throws AFTER
380
- // the whole row is written, so catching it here is the only
381
- // place the row survives intact).
382
399
  if ( !Number.isInteger( tsValue ) && typeof tsValue !== 'bigint' ) {
383
- onWarning(
384
- `designatedTimestamp '${designatedTimestamp}' is ${skipReason( tsValue, true, 'timestamp' )} ` +
385
- `in insightType '${insightTypeName}' (asset: ${partitionId}) - row skipped`
386
- );
400
+ warnRowSkipped( tsValue, partitionId );
387
401
  return false;
388
402
  }
389
403
 
@@ -391,9 +405,10 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
391
405
  // That column always stores the partition id. A record value
392
406
  // that differs is almost always one mistake: trying to relabel
393
407
  // identity in the record. It is reported once per insightType,
394
- // never silently dropped (ADR-018 no-silent-failures). The
395
- // flag advances only after onWarning returns. So under strict
396
- // mode, where onWarning throws, every mismatched row is
408
+ // never silently dropped (ADR-018 no-silent-failures).
409
+ //
410
+ // The flag advances only after onWarning returns. So under
411
+ // strict mode, where onWarning throws, every mismatched row is
397
412
  // rejected here with the sender untouched.
398
413
  if ( !assetIdMismatchWarned && message.assetId !== undefined && message.assetId !== partitionId ) {
399
414
  onWarning(
@@ -405,22 +420,20 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
405
420
  }
406
421
 
407
422
  // Check every column value against its declared type. A failed
408
- // check marks the column for a silent skip in phase 2 (QuestDB
409
- // stores NULL) — the same treatment null already gets — and
410
- // warns HERE, before the row opens, so an onWarning that throws
411
- // (documented strict mode) rejects the row with the sender
412
- // untouched instead of wedging it mid-row. Composer's NaN
413
- // propagation ends here exactly as before: a NaN in a numeric
414
- // column fails its acceptance check and lands as a NULL column
415
- // while the row survives.
423
+ // check marks the column for a silent skip in phase 2, where
424
+ // QuestDB stores NULL. That is the treatment null already gets.
425
+ // The warning fires HERE, before the row opens. So an onWarning
426
+ // that throws (documented strict mode) rejects the row with the
427
+ // sender untouched instead of wedging it mid-row.
428
+ //
429
+ // Composer's NaN propagation ends here exactly as before. A
430
+ // NaN in a numeric column fails its acceptance check and lands
431
+ // as a NULL column while the row survives.
416
432
  for ( let k = 0; k < stepCount; k += 1 ) {
417
433
  const rawValue = message[ stepNames[ k ] ];
418
434
  stepValueOk[ k ] = ( rawValue !== null ) && ( rawValue !== undefined ) && stepAccepts[ k ]( rawValue );
419
435
  if ( !stepValueOk[ k ] ) {
420
- onWarning(
421
- `column '${stepNames[ k ]}' is ${skipReason( rawValue, stepIsNumeric[ k ], stepTypes[ k ] )} ` +
422
- `in insightType '${insightTypeName}' (asset: ${partitionId}) — column skipped`
423
- );
436
+ stepWarnings[ k ]( rawValue, partitionId );
424
437
  }
425
438
  }
426
439
 
@@ -437,41 +450,13 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
437
450
  // about in phase 1 — before the row opened.
438
451
  }
439
452
 
440
- // Designated timestamp (ends the row in ILP).
441
- //
442
- // Async flush failure handling — the no-silent-failures contract:
443
- //
444
- // sender.at() is async — the buffer mutation is sync but the
445
- // trailing `await this.tryFlush()` may fire a network flush.
446
- // When that flush fails (HTTP timeout, buffer overflow at the
447
- // client's max_buf_size, QDB unreachable), the rows in that
448
- // batch are dropped. Composer must NOT silently lose data.
449
- //
450
- // Routing rules:
451
- // - If a caller provided `onDeliveryFailure`, route the
452
- // failure to it. The caller owns the response (log,
453
- // retry-budget, alert, stop the flow).
454
- // - Otherwise, throw a classified `DELIVERY_FAILED` error.
455
- // Inside a .catch() handler this surfaces as an
456
- // unhandled rejection — Node logs it loudly and (15+)
457
- // terminates the process. Loud failure beats silent
458
- // loss every time.
453
+ // Designated timestamp (ends the row in ILP). sender.at() is
454
+ // async; its promise rejects when the append itself threw
455
+ // (see the file header). The prebuilt handler routes the
456
+ // rejection; a mock at() that returns no promise is skipped.
459
457
  const atResult = sender.at( tsValue, 'ms' );
460
458
  if ( atResult && typeof atResult.catch === 'function' ) {
461
- atResult.catch( ( err ) => {
462
- if ( onDeliveryFailure ) {
463
- onDeliveryFailure( err, { tableName } );
464
- return;
465
- }
466
- const failure = new Error(
467
- `WinkComposer/questdb: silent data loss — sender.at() async flush failed for table '${tableName}': ${err.message}. ` +
468
- 'Rows in this batch were dropped. Per the no-silent-failures contract this is a hard failure. ' +
469
- 'Provide an `onDeliveryFailure` callback in the storage config to handle these explicitly.'
470
- );
471
- failure.code = 'DELIVERY_FAILED';
472
- failure.cause = err;
473
- throw failure;
474
- } );
459
+ atResult.catch( onAppendRejected );
475
460
  }
476
461
 
477
462
  return true;
@@ -485,4 +470,4 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
485
470
  // EXPORTS
486
471
  // ============================================================================
487
472
 
488
- export { buildPersistPlans, defaultOnWarning };
473
+ export { buildPersistPlans };