@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
@@ -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,6 +97,7 @@
83
97
  import { SenderBufferV1 } from '@questdb/nodejs-client';
84
98
 
85
99
  import { QUEST_WRITERS, writeAsString, createFloat64Writer } from './writers.js';
100
+ import { defaultOnWarning, makeColumnWarning, makeRowWarning } from './skip-warnings.js';
86
101
  import { wrapCallback } from '../../utils/callback-guard/index.js';
87
102
  import { logger } from '../../logger/index.js';
88
103
 
@@ -97,33 +112,6 @@ const reportCallbackFault = function ( severity, name, detail ) {
97
112
  );
98
113
  }; // reportCallbackFault()
99
114
 
100
- // ============================================================================
101
- // DEFAULT WARNING HANDLER
102
- // ============================================================================
103
-
104
- /**
105
- * Default warning handler for invalid column values.
106
- * Logs to console in winkComposer format.
107
- *
108
- * Validation behavior:
109
- * - null/undefined columns: skip column only (QuestDB stores NULL)
110
- * - NaN/Infinity in numeric columns: skip column only (QuestDB stores NULL)
111
- * - Wrong-typed column values (e.g. a number where the column is string-typed):
112
- * skip column only, never coerce — the warning names the expected and
113
- * received types
114
- * - Invalid designatedTimestamp: skip entire row
115
- *
116
- * For strict mode (throw on any invalid), provide:
117
- * { onWarning: (msg) => { throw new Error(msg); } }
118
- *
119
- * Future extension: return value may control skip-row vs skip-column behavior.
120
- *
121
- * @param {string} message - Warning message describing the issue
122
- */
123
- const defaultOnWarning = function ( message ) {
124
- logger.warn( `winkComposer/questdb: ${message}` );
125
- };
126
-
127
115
  /**
128
116
  * Check if column type requires numeric validation (NaN/Infinity check).
129
117
  *
@@ -166,29 +154,6 @@ ACCEPTS.bool = ( value ) => typeof value === 'boolean';
166
154
  */
167
155
  const acceptAny = () => true;
168
156
 
169
- /**
170
- * Builds the reason text for a value that failed its phase-1 acceptance
171
- * check. Called only on the rare skip path, so its allocations are
172
- * acceptable (same budget as the error returns in index.js).
173
- *
174
- * @param {*} rawValue - the rejected value
175
- * @param {boolean} isNumeric - whether the column type is numeric
176
- * @param {string} expectedType - declared column type, named in the message
177
- * @returns {string} plain reason text for the skip warning
178
- */
179
- const skipReason = function ( rawValue, isNumeric, expectedType ) {
180
- if ( rawValue === null ) return 'null';
181
- if ( rawValue === undefined ) return 'undefined';
182
- if ( isNumeric && typeof rawValue === 'number' ) {
183
- if ( Number.isNaN( rawValue ) ) return 'NaN';
184
- // A finite number can only be rejected by the integer-required
185
- // types (int64/timestamp/designated timestamp) — float64 accepts
186
- // every finite number.
187
- return Number.isFinite( rawValue ) ? 'non-integer' : 'non-finite';
188
- }
189
- return `wrong-typed (expected ${expectedType}, received ${typeof rawValue})`;
190
- }; // skipReason()
191
-
192
157
  /**
193
158
  * Asserts every ILP name a plan will write — the table name and each column
194
159
  * name — by driving them once through a throwaway client buffer, so the
@@ -264,11 +229,11 @@ const assertIlpNames = function ( tableName, columnNames ) {
264
229
  * @param {string} tablePrefix - Prefix for table names (typically assetClass.name)
265
230
  * @param {Object} [options] - Optional configuration
266
231
  * @param {function} [options.onWarning] - Soft per-row warning callback for
267
- * invalid values (NaN, null, invalid timestamp). Default: console.warn.
268
- * @param {function} [options.onDeliveryFailure] - Hard batch-level failure
269
- * callback for `sender.at()` async flush rejection (HTTP timeout, buffer
270
- * overflow, QDB unreachable). Default: throw — surfaces as an unhandled
271
- * 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.
272
237
  * @returns {Object.<string, function>} Map of insightType to persistRow function
273
238
  */
274
239
  const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
@@ -287,16 +252,19 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
287
252
  throw err;
288
253
  }
289
254
 
290
- const onWarning = providedOnWarning || defaultOnWarning;
291
255
  // onWarning stays raw on purpose. A strict-mode onWarning throws,
292
256
  // and that throw is the instruction that rejects the row. ADR-027
293
257
  // keeps such callbacks — ones whose throw or return the adapter
294
- // acts on — out of the guard's scope. onDeliveryFailure only
295
- // notifies, so the shared guard arms it. It was validated raw
296
- // above and is wrapped once here (ADR-018: a broken handler costs
297
- // its own output, never the flush chain). Absent stays null, so
298
- // the no-handler DELIVERY_FAILED escape hatch below keeps its
299
- // exact meaning.
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.
261
+ const onWarning = providedOnWarning || defaultOnWarning;
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.
300
268
  const onDeliveryFailure = wrapCallback( providedOnDeliveryFailure || null, {
301
269
  name: 'onDeliveryFailure', severity: 'red', report: reportCallbackFault
302
270
  } );
@@ -316,10 +284,12 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
316
284
  // column itself, from the partition id. A persisted column with
317
285
  // the same name would make the CREATE TABLE ask for 'assetId'
318
286
  // twice. That happens whether the name appears in the columns
319
- // list or as the designated timestamp. Fail fast here and name
320
- // the fix; otherwise QuestDB answers at table creation with its
321
- // raw "Duplicate column" error. A dictionary column named
322
- // '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.
323
293
  if ( persistedColumnNames.includes( 'assetId' ) || designatedTimestamp === 'assetId' ) {
324
294
  const err = new Error(
325
295
  `winkComposer/questdb: insightType '${insightTypeName}' uses reserved column name 'assetId' — ` +
@@ -330,31 +300,34 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
330
300
  throw err;
331
301
  }
332
302
 
333
- // Pre-compile writer steps
334
- // designatedTimestamp is handled separately via sender.at()
335
- // 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.
336
306
  const stepNames = [];
337
307
  const stepWriters = [];
338
- const stepIsNumeric = [];
339
- const stepTypes = [];
340
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 = [];
341
312
 
342
313
  for ( let j = 0; j < persistedColumnNames.length; j += 1 ) {
343
314
  const columnName = persistedColumnNames[ j ];
344
315
 
345
- // Skip designatedTimestamp - handled separately via sender.at()
316
+ // Skip the designated timestamp. It is handled separately, via
317
+ // sender.at().
346
318
  if ( columnName !== designatedTimestamp ) {
347
319
  const columnSpec = columns[ columnName ];
348
320
  const columnType = columnSpec ? columnSpec.type : 'string';
349
321
 
350
322
  stepNames.push( columnName );
351
- stepIsNumeric.push( isNumericType( columnType ) );
352
- stepTypes.push( columnType );
353
323
  stepAccepts.push( ACCEPTS[ columnType ] || acceptAny );
324
+ stepWarnings.push( makeColumnWarning(
325
+ providedOnWarning, insightTypeName, columnName, isNumericType( columnType ), columnType
326
+ ) );
354
327
 
355
- // float64 columns use resolution-aware writer factory
356
- // Note: columnSpec is guaranteed to exist here since columnType='float64'
357
- // 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'.
358
331
  if ( columnType === 'float64' ) {
359
332
  stepWriters.push( createFloat64Writer( columnSpec.resolution ) );
360
333
  } else {
@@ -365,6 +338,28 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
365
338
 
366
339
  const stepCount = stepNames.length;
367
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()
368
363
  // Fail-fast at startup: a bad table or column name would otherwise
369
364
  // throw inside the client mid-row at write time.
370
365
  assertIlpNames( tableName, stepNames );
@@ -378,36 +373,31 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
378
373
  // One report carries all the information.
379
374
  let assetIdMismatchWarned = false;
380
375
 
381
- // Create closure that captures pre-resolved references
382
- // Interface: persistRow(sender, message, partitionId) -> boolean
383
- // Returns true when a row was opened and completed on the sender,
384
- // false when phase 1 skipped the whole row (the sender was never
385
- // touched). The caller's buffered-row accounting keys off this —
386
- // a skipped row must not count as buffered.
387
- // 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.
388
386
  plansByInsightType[ insightTypeName ] = function ( sender, message, partitionId ) {
389
387
  // ---- Phase 1: validate. No sender calls — nothing irreversible
390
388
  // happens until every value has been checked (see file header).
391
389
 
392
- // 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.
393
398
  const tsValue = message[ designatedTimestamp ];
394
- if ( tsValue === undefined || tsValue === null ) {
395
- onWarning(
396
- `designatedTimestamp '${designatedTimestamp}' is ${tsValue === null ? 'null' : 'undefined'} ` +
397
- `in insightType '${insightTypeName}' (asset: ${partitionId}) - row skipped`
398
- );
399
- return false;
400
- }
401
- // Integer-or-bigint, matching the client's own .at() validation
402
- // (probe-verified: at( ...000.5, 'ms' ) throws "Designated
403
- // timestamp must be an integer or BigInt" — and it throws AFTER
404
- // the whole row is written, so catching it here is the only
405
- // place the row survives intact).
406
399
  if ( !Number.isInteger( tsValue ) && typeof tsValue !== 'bigint' ) {
407
- onWarning(
408
- `designatedTimestamp '${designatedTimestamp}' is ${skipReason( tsValue, true, 'timestamp' )} ` +
409
- `in insightType '${insightTypeName}' (asset: ${partitionId}) - row skipped`
410
- );
400
+ warnRowSkipped( tsValue, partitionId );
411
401
  return false;
412
402
  }
413
403
 
@@ -415,9 +405,10 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
415
405
  // That column always stores the partition id. A record value
416
406
  // that differs is almost always one mistake: trying to relabel
417
407
  // identity in the record. It is reported once per insightType,
418
- // never silently dropped (ADR-018 no-silent-failures). The
419
- // flag advances only after onWarning returns. So under strict
420
- // 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
421
412
  // rejected here with the sender untouched.
422
413
  if ( !assetIdMismatchWarned && message.assetId !== undefined && message.assetId !== partitionId ) {
423
414
  onWarning(
@@ -429,22 +420,20 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
429
420
  }
430
421
 
431
422
  // Check every column value against its declared type. A failed
432
- // check marks the column for a silent skip in phase 2 (QuestDB
433
- // stores NULL) — the same treatment null already gets — and
434
- // warns HERE, before the row opens, so an onWarning that throws
435
- // (documented strict mode) rejects the row with the sender
436
- // untouched instead of wedging it mid-row. Composer's NaN
437
- // propagation ends here exactly as before: a NaN in a numeric
438
- // column fails its acceptance check and lands as a NULL column
439
- // 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.
440
432
  for ( let k = 0; k < stepCount; k += 1 ) {
441
433
  const rawValue = message[ stepNames[ k ] ];
442
434
  stepValueOk[ k ] = ( rawValue !== null ) && ( rawValue !== undefined ) && stepAccepts[ k ]( rawValue );
443
435
  if ( !stepValueOk[ k ] ) {
444
- onWarning(
445
- `column '${stepNames[ k ]}' is ${skipReason( rawValue, stepIsNumeric[ k ], stepTypes[ k ] )} ` +
446
- `in insightType '${insightTypeName}' (asset: ${partitionId}) — column skipped`
447
- );
436
+ stepWarnings[ k ]( rawValue, partitionId );
448
437
  }
449
438
  }
450
439
 
@@ -461,41 +450,13 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
461
450
  // about in phase 1 — before the row opened.
462
451
  }
463
452
 
464
- // Designated timestamp (ends the row in ILP).
465
- //
466
- // Async flush failure handling — the no-silent-failures contract:
467
- //
468
- // sender.at() is async — the buffer mutation is sync but the
469
- // trailing `await this.tryFlush()` may fire a network flush.
470
- // When that flush fails (HTTP timeout, buffer overflow at the
471
- // client's max_buf_size, QDB unreachable), the rows in that
472
- // batch are dropped. Composer must NOT silently lose data.
473
- //
474
- // Routing rules:
475
- // - If a caller provided `onDeliveryFailure`, route the
476
- // failure to it. The caller owns the response (log,
477
- // retry-budget, alert, stop the flow).
478
- // - Otherwise, throw a classified `DELIVERY_FAILED` error.
479
- // Inside a .catch() handler this surfaces as an
480
- // unhandled rejection — Node logs it loudly and (15+)
481
- // terminates the process. Loud failure beats silent
482
- // 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.
483
457
  const atResult = sender.at( tsValue, 'ms' );
484
458
  if ( atResult && typeof atResult.catch === 'function' ) {
485
- atResult.catch( ( err ) => {
486
- if ( onDeliveryFailure ) {
487
- onDeliveryFailure( err, { tableName } );
488
- return;
489
- }
490
- const failure = new Error(
491
- `winkComposer/questdb: silent data loss — sender.at() async flush failed for table '${tableName}': ${err.message}. ` +
492
- 'Rows in this batch were dropped. Per the no-silent-failures contract this is a hard failure. ' +
493
- 'Provide an `onDeliveryFailure` callback in the storage config to handle these explicitly.'
494
- );
495
- failure.code = 'DELIVERY_FAILED';
496
- failure.cause = err;
497
- throw failure;
498
- } );
459
+ atResult.catch( onAppendRejected );
499
460
  }
500
461
 
501
462
  return true;
@@ -509,4 +470,4 @@ const buildPersistPlans = function ( assetClass, tablePrefix, options ) {
509
470
  // EXPORTS
510
471
  // ============================================================================
511
472
 
512
- export { buildPersistPlans, defaultOnWarning };
473
+ export { buildPersistPlans };