node-firebird 2.12.0 → 2.14.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.
@@ -0,0 +1,319 @@
1
+ /***************************************
2
+ *
3
+ * PoolCluster — multi-host pooling (primaries/replicas, failover)
4
+ *
5
+ * The mysql2 PoolCluster model on top of this driver's Pool: named
6
+ * nodes, each backed by a regular connection pool (health checks,
7
+ * recycling and metrics included), selected by glob pattern +
8
+ * selector. Consecutive connection failures take a node offline
9
+ * (with optional timed restoration), and get() fails over to the
10
+ * next matching online node.
11
+ *
12
+ ***************************************/
13
+
14
+ import Events from 'events';
15
+ import { fromCallback, withPooledConnection } from './callback';
16
+ import type { Callback } from './callback';
17
+ import { parseConnectionString } from './uri';
18
+ import Pool from './pool';
19
+
20
+ type AttachFn = (options: any, callback: Callback) => void;
21
+
22
+ export type ClusterSelector = 'rr' | 'random' | 'order';
23
+
24
+ export interface PoolClusterOptions {
25
+ /** Options shared by every node (user, password, database, …). */
26
+ defaults?: any;
27
+ /** name → per-node option overrides (host, port, …). */
28
+ nodes?: Record<string, any>;
29
+ /** Per-node pool size (default 4). */
30
+ max?: number;
31
+ /** Default selector for get()/of() (default 'rr'). */
32
+ selector?: ClusterSelector;
33
+ /**
34
+ * Consecutive connection failures after which a node goes offline
35
+ * (default 5; 0 disables offlining).
36
+ */
37
+ removeNodeErrorCount?: number;
38
+ /**
39
+ * Milliseconds after which an offline node is restored and probed
40
+ * again (default 30000; 0 = stay offline until restore()/remove()).
41
+ */
42
+ restoreNodeTimeout?: number;
43
+ }
44
+
45
+ interface ClusterNode {
46
+ name: string;
47
+ options: any;
48
+ pool: Pool;
49
+ online: boolean;
50
+ errorCount: number;
51
+ restoreTimer: NodeJS.Timeout | null;
52
+ }
53
+
54
+ function patternToRegExp(pattern: string): RegExp {
55
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
56
+ return new RegExp('^' + escaped + '$');
57
+ }
58
+
59
+ /**
60
+ * Events: 'online' (name) — node restored; 'offline' (name) — node taken
61
+ * out of rotation after too many connection failures; 'remove' (name) —
62
+ * node removed via remove().
63
+ */
64
+ class PoolCluster extends Events.EventEmitter {
65
+ private attach: AttachFn;
66
+ private nodes = new Map<string, ClusterNode>();
67
+ private rrIndex = new Map<string, number>();
68
+ private max: number;
69
+ private defaults: any;
70
+ private selector: ClusterSelector;
71
+ private removeNodeErrorCount: number;
72
+ private restoreNodeTimeout: number;
73
+ private _destroyed = false;
74
+
75
+ constructor(attach: AttachFn, options?: PoolClusterOptions) {
76
+ super();
77
+ options = options || {};
78
+ this.attach = attach;
79
+ this.defaults = options.defaults || {};
80
+ this.max = options.max && options.max > 0 ? options.max : 4;
81
+ this.selector = options.selector || 'rr';
82
+ this.removeNodeErrorCount = options.removeNodeErrorCount !== undefined ? options.removeNodeErrorCount : 5;
83
+ this.restoreNodeTimeout = options.restoreNodeTimeout !== undefined ? options.restoreNodeTimeout : 30000;
84
+
85
+ for (const [name, overrides] of Object.entries(options.nodes || {})) {
86
+ this.add(name, overrides);
87
+ }
88
+ }
89
+
90
+ /** Register a node; its pool is created lazily-safe right away. */
91
+ add(name: string, overrides?: any): this {
92
+ if (this._destroyed) {
93
+ throw new Error('PoolCluster has been destroyed');
94
+ }
95
+ if (this.nodes.has(name)) {
96
+ throw new Error('PoolCluster node already exists: ' + name);
97
+ }
98
+ // a connection-string override must be parsed, not object-spread
99
+ // into character-indexed garbage
100
+ if (typeof overrides === 'string') {
101
+ overrides = parseConnectionString(overrides);
102
+ }
103
+ const nodeOptions = { ...this.defaults, ...(overrides || {}) };
104
+ this.nodes.set(name, {
105
+ name,
106
+ options: nodeOptions,
107
+ pool: new Pool(this.attach, nodeOptions.max || this.max, { ...nodeOptions, isPool: true }),
108
+ online: true,
109
+ errorCount: 0,
110
+ restoreTimer: null,
111
+ });
112
+ return this;
113
+ }
114
+
115
+ /** Remove a node for good, destroying its pool. */
116
+ remove(name: string, callback?: (err?: any) => void): void {
117
+ const node = this.nodes.get(name);
118
+ if (!node) {
119
+ if (callback) callback();
120
+ return;
121
+ }
122
+ this.nodes.delete(name);
123
+ if (node.restoreTimer) {
124
+ clearTimeout(node.restoreTimer);
125
+ }
126
+ this.emit('remove', name);
127
+ node.pool.destroy(callback);
128
+ }
129
+
130
+ /** Bring an offline node back into rotation immediately. */
131
+ restore(name: string): void {
132
+ const node = this.nodes.get(name);
133
+ if (!node || node.online) {
134
+ return;
135
+ }
136
+ if (node.restoreTimer) {
137
+ clearTimeout(node.restoreTimer);
138
+ node.restoreTimer = null;
139
+ }
140
+ node.online = true;
141
+ node.errorCount = 0;
142
+ this.emit('online', name);
143
+ }
144
+
145
+ /** name → { online, errorCount, pool metrics } for every node. */
146
+ status(): Record<string, any> {
147
+ const out: Record<string, any> = {};
148
+ for (const node of this.nodes.values()) {
149
+ out[node.name] = {
150
+ online: node.online,
151
+ errorCount: node.errorCount,
152
+ totalCount: node.pool.totalCount,
153
+ idleCount: node.pool.idleCount,
154
+ activeCount: node.pool.activeCount,
155
+ waitingCount: node.pool.waitingCount,
156
+ };
157
+ }
158
+ return out;
159
+ }
160
+
161
+ private matching(pattern: string): ClusterNode[] {
162
+ const re = patternToRegExp(pattern);
163
+ const out: ClusterNode[] = [];
164
+ for (const node of this.nodes.values()) {
165
+ if (re.test(node.name)) {
166
+ out.push(node);
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ private pick(pattern: string, selector: ClusterSelector, exclude: Set<string>): ClusterNode | null {
173
+ const candidates = this.matching(pattern).filter((n) => n.online && !exclude.has(n.name));
174
+ if (!candidates.length) {
175
+ return null;
176
+ }
177
+ if (selector === 'random') {
178
+ return candidates[Math.floor(Math.random() * candidates.length)];
179
+ }
180
+ if (selector === 'order') {
181
+ return candidates[0];
182
+ }
183
+ // round-robin per pattern; only the FIRST pick of a get() advances
184
+ // the counter — failover re-picks reuse it, or a run of failovers
185
+ // would skew the distribution toward nodes after the failing ones
186
+ const index = this.rrIndex.get(pattern) || 0;
187
+ if (exclude.size === 0) {
188
+ this.rrIndex.set(pattern, index + 1);
189
+ }
190
+ return candidates[index % candidates.length];
191
+ }
192
+
193
+ private noteFailure(node: ClusterNode): void {
194
+ // a node removed while a get was in flight must not accumulate
195
+ // counters, emit 'offline', or arm a restore timer nobody clears
196
+ if (!this.nodes.has(node.name)) {
197
+ return;
198
+ }
199
+ node.errorCount++;
200
+ if (!this.removeNodeErrorCount || node.errorCount < this.removeNodeErrorCount || !node.online) {
201
+ return;
202
+ }
203
+ node.online = false;
204
+ this.emit('offline', node.name);
205
+ if (this.restoreNodeTimeout > 0) {
206
+ node.restoreTimer = setTimeout(() => {
207
+ node.restoreTimer = null;
208
+ this.restore(node.name);
209
+ }, this.restoreNodeTimeout);
210
+ if (node.restoreTimer.unref) {
211
+ node.restoreTimer.unref();
212
+ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Acquire a connection from a node matching `pattern` (default '*').
218
+ * Connection failures mark the node and FAIL OVER to the next
219
+ * matching online node; only when every candidate has failed does the
220
+ * callback receive the last error. Release connections with
221
+ * db.detach(), exactly like a plain pool.
222
+ */
223
+ get(pattern: string | Callback, selector?: ClusterSelector | Callback, callback?: Callback): void {
224
+ if (typeof pattern === 'function') {
225
+ callback = pattern;
226
+ pattern = '*';
227
+ }
228
+ if (typeof selector === 'function') {
229
+ callback = selector;
230
+ selector = undefined;
231
+ }
232
+ if (this._destroyed) {
233
+ callback!(new Error('PoolCluster has been destroyed'), null);
234
+ return;
235
+ }
236
+
237
+ const sel = (selector as ClusterSelector) || this.selector;
238
+ const tried = new Set<string>();
239
+ const self = this;
240
+
241
+ const attempt = (lastError?: any) => {
242
+ const node = self.pick(pattern as string, sel, tried);
243
+ if (!node) {
244
+ callback!(lastError || new Error('PoolCluster: no online node matches pattern "' + pattern + '"'), null);
245
+ return;
246
+ }
247
+ tried.add(node.name);
248
+ node.pool.get((err: any, db: any) => {
249
+ if (err) {
250
+ self.noteFailure(node);
251
+ attempt(err);
252
+ return;
253
+ }
254
+ node.errorCount = 0;
255
+ callback!(null, db);
256
+ });
257
+ };
258
+ attempt();
259
+ }
260
+
261
+ getAsync(pattern?: string, selector?: ClusterSelector): Promise<any> {
262
+ const self = this;
263
+ return fromCallback((cb) => self.get(pattern || '*', selector, cb));
264
+ }
265
+
266
+ /**
267
+ * A pool-like facade bound to a pattern (mysql2's cluster.of):
268
+ * { get, getAsync, withConnection } routed through the cluster's
269
+ * selection and failover.
270
+ */
271
+ of(pattern: string, selector?: ClusterSelector) {
272
+ const self = this;
273
+ return {
274
+ get(callback: Callback) {
275
+ self.get(pattern, selector, callback);
276
+ },
277
+ getAsync() {
278
+ return self.getAsync(pattern, selector);
279
+ },
280
+ withConnection<T>(work: (db: any) => Promise<T> | T): Promise<T> {
281
+ return self.withConnection(pattern, work, selector);
282
+ },
283
+ };
284
+ }
285
+
286
+ /** Run `work` with a connection from a matching node, always released. */
287
+ withConnection<T>(pattern: string, work: (db: any) => Promise<T> | T, selector?: ClusterSelector): Promise<T> {
288
+ return withPooledConnection(() => this.getAsync(pattern, selector), work);
289
+ }
290
+
291
+ /** Destroy every node's pool. */
292
+ destroy(callback?: (err?: any) => void): void {
293
+ this._destroyed = true;
294
+ const nodes = [...this.nodes.values()];
295
+ this.nodes.clear();
296
+ let remaining = nodes.length;
297
+ if (!remaining) {
298
+ if (callback) callback();
299
+ return;
300
+ }
301
+ let firstError: any = null;
302
+ for (const node of nodes) {
303
+ if (node.restoreTimer) {
304
+ clearTimeout(node.restoreTimer);
305
+ }
306
+ node.pool.destroy((err?: any) => {
307
+ if (err && !firstError) firstError = err;
308
+ if (--remaining === 0 && callback) callback(firstError);
309
+ });
310
+ }
311
+ }
312
+
313
+ destroyAsync(): Promise<void> {
314
+ const self = this;
315
+ return fromCallback((cb) => self.destroy(cb));
316
+ }
317
+ }
318
+
319
+ export default PoolCluster;
package/src/pool.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  ***************************************/
6
6
 
7
7
  import Events from 'events';
8
- import { fromCallback } from './callback';
8
+ import { fromCallback, withPooledConnection } from './callback';
9
9
  import type { Callback } from './callback';
10
10
 
11
11
  type AttachFn = (options: any, callback: Callback) => void;
@@ -385,15 +385,8 @@ class Pool extends Events.EventEmitter {
385
385
  * Run `work` with a connection from the pool, returning it to the pool
386
386
  * (detach) when the returned promise settles — success or failure.
387
387
  */
388
- async withConnection<T>(work: (db: any) => Promise<T> | T): Promise<T> {
389
- const db = await this.getAsync();
390
- try {
391
- return await work(db);
392
- } finally {
393
- // A pooled detach only returns the connection to the pool; do not
394
- // let a detach hiccup mask the outcome of `work`.
395
- await new Promise<void>(function(resolve) { db.detach(function() { resolve(); }); });
396
- }
388
+ withConnection<T>(work: (db: any) => Promise<T> | T): Promise<T> {
389
+ return withPooledConnection(() => this.getAsync(), work);
397
390
  }
398
391
  }
399
392
 
package/src/types.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // They now live in the TypeScript source tree and are compiled into the
6
6
  // published declaration files.
7
7
 
8
- import type { Readable } from 'stream';
8
+ import type { Readable, Writable } from 'stream';
9
9
  import type { SqlTag } from './sql-template';
10
10
 
11
11
  export type { SqlTag, SqlQuery, SqlIdentifier, CompiledQuery } from './sql-template';
@@ -211,6 +211,22 @@ export interface QueryResult<T = any> {
211
211
  warnings: ServerWarning[];
212
212
  }
213
213
 
214
+ /** Options for batchStream: the executeBatch options plus stream tuning. */
215
+ export type BatchStreamOptions = BatchOptions & {
216
+ /** Rows buffered per executeBatch flush (default 1000). */
217
+ flushRows?: number;
218
+ /** Writable highWaterMark in rows (default: flushRows). */
219
+ highWaterMark?: number;
220
+ };
221
+
222
+ /** The Writable returned by batchStream, with totals valid after 'finish'. */
223
+ export interface BatchStream extends Writable {
224
+ /** Records the server processed so far. */
225
+ recordCount: number;
226
+ /** Sum of per-record update counts so far. */
227
+ affectedRows: number;
228
+ }
229
+
214
230
  export type QueryStreamOptions = QueryOptions & {
215
231
  /**
216
232
  * Rows buffered internally before fetching pauses (object-mode
@@ -232,8 +248,8 @@ export interface Database {
232
248
  detach(callback?: SimpleCallback): Database;
233
249
  transaction(options: TransactionOptions|Isolation|TransactionCallback, callback?: TransactionCallback): Database;
234
250
  newStatement(query: string, callback: (err: Error | null, statement: Statement) => void): Database;
235
- query(query: string, params: QueryParams, callback: QueryCallback, options?: QueryOptions): Database;
236
- execute(query: string, params: QueryParams, callback: QueryCallback, options?: QueryOptions): Database;
251
+ query<T = any>(query: string, params: QueryParams, callback: (err: any, result: T[], meta?: any[], isSelect?: boolean) => void, options?: QueryOptions): Database;
252
+ execute<T = any>(query: string, params: QueryParams, callback: (err: any, result: T[], meta?: any[], isSelect?: boolean) => void, options?: QueryOptions): Database;
237
253
  /** Bulk-execute in its own transaction, all-or-nothing (Firebird 4.0+). */
238
254
  executeBatch(query: string, rows: QueryParams[], callback?: (err: any, result: BatchResult) => void, options?: BatchOptions): Database;
239
255
  sequentially(query: string, params: QueryParams, rowCallback: SequentialCallback, callback: SimpleCallback, options?: QueryOptions | boolean): Database;
@@ -244,6 +260,13 @@ export interface Database {
244
260
  * fetch and releases the statement.
245
261
  */
246
262
  queryStream(query: string, params?: QueryParams, options?: QueryStreamOptions): Readable;
263
+ /**
264
+ * Bulk-insert Writable (COPY FROM analogue, Firebird 4.0+): write
265
+ * parameter-array rows; they are flushed in chunks through the batch
266
+ * API. Runs its own transaction — committed on finish, rolled back on
267
+ * error/destroy. BLOB columns accept Buffers/strings.
268
+ */
269
+ batchStream(query: string, options?: BatchStreamOptions): BatchStream;
247
270
  drop(callback: SimpleCallback): void;
248
271
  escape(value: any): string;
249
272
  attachEvent(callback: any): this;
@@ -290,8 +313,8 @@ export interface Transaction {
290
313
  */
291
314
  savepoint<T>(work: (transaction: Transaction) => Promise<T> | T): Promise<T>;
292
315
  newStatement(query: string, callback: (err: Error | null, statement: Statement) => void): void;
293
- query(query: string, params: QueryParams, callback: QueryCallback, options?: QueryOptions): void;
294
- execute(query: string, params: QueryParams, callback: QueryCallback, options?: QueryOptions): void;
316
+ query<T = any>(query: string, params: QueryParams, callback: (err: any, result: T[], meta?: any[], isSelect?: boolean) => void, options?: QueryOptions): void;
317
+ execute<T = any>(query: string, params: QueryParams, callback: (err: any, result: T[], meta?: any[], isSelect?: boolean) => void, options?: QueryOptions): void;
295
318
  /** Bulk-execute within this transaction; per-record failures do not roll back (Firebird 4.0+). */
296
319
  executeBatch(query: string, rows: QueryParams[], callback?: (err: any, result: BatchResult) => void, options?: BatchOptions): void;
297
320
  sequentially(query: string, params: QueryParams, rowCallback: SequentialCallback, callback: SimpleCallback, options?: QueryOptions | boolean): Database;
@@ -301,6 +324,11 @@ export interface Transaction {
301
324
  * transaction is NOT committed when the stream ends.
302
325
  */
303
326
  queryStream(query: string, params?: QueryParams, options?: QueryStreamOptions): Readable;
327
+ /**
328
+ * Bulk-insert Writable inside this transaction (see
329
+ * Database.batchStream); commit/rollback stays with the caller.
330
+ */
331
+ batchStream(query: string, options?: BatchStreamOptions): BatchStream;
304
332
  commit(callback?: SimpleCallback): void;
305
333
  commitRetaining(callback?: SimpleCallback): void;
306
334
  rollback(callback?: SimpleCallback): void;
@@ -416,6 +444,13 @@ export interface Options {
416
444
  * per-query `namedPlaceholders: false` override.
417
445
  */
418
446
  namedPlaceholders?: boolean;
447
+ /**
448
+ * Default character set of a NEWLY CREATED database (create /
449
+ * attachOrCreate only). Falls back to the connection `encoding`, then
450
+ * UTF8 — pass e.g. `defaultCharset: 'UTF8'` to keep a modern database
451
+ * default while connecting with a legacy codepage `encoding`.
452
+ */
453
+ defaultCharset?: string;
419
454
  /**
420
455
  * Qualify object-row keys by source table (same option as mysql2), so
421
456
  * JOINed columns with the same name stop overwriting each other:
package/src/utils.ts CHANGED
@@ -108,6 +108,21 @@ export const parseDate = (str: string): Date => {
108
108
  /**
109
109
  * Get Error Message per gdscode
110
110
  */
111
+ /**
112
+ * Turn a failed executeBatch completion into the all-or-nothing error
113
+ * shape shared by database.executeBatch and batchStream: the first
114
+ * record's own error (or a synthesized summary), with the full
115
+ * completion attached as err.batchCompletion.
116
+ */
117
+ export const batchResultToError = (result: { errors: { error: any }[]; errorRecordNumbers: number[] }): any => {
118
+ const first = result.errors.length ? result.errors[0] : null;
119
+ const err: any = first
120
+ ? first.error
121
+ : new Error('Batch failed for record(s) ' + result.errorRecordNumbers.join(', '));
122
+ err.batchCompletion = result;
123
+ return err;
124
+ };
125
+
111
126
  export const lookupMessages = (status: FbStatusItem[]): string => {
112
127
  const messages = status.map((item) => {
113
128
  let text = MessagesError[item.gdscode];
@@ -143,7 +158,10 @@ export const escape = function(value: any, protocolVersion?: number): string {
143
158
  case 'number':
144
159
  return value.toString();
145
160
  case 'string':
146
- return "'" + value.replace(/'/g, "''").replace(/\\/g, '\\\\') + "'";
161
+ // Firebird string literals have NO backslash escapes — only the
162
+ // quote is doubled. Doubling backslashes corrupted the data
163
+ // (issue #156: '\' arrived as '\\').
164
+ return "'" + value.replace(/'/g, "''") + "'";
147
165
  }
148
166
 
149
167
  if (value instanceof Date)
@@ -0,0 +1,121 @@
1
+ /***************************************
2
+ *
3
+ * batchStream — object-mode Writable over the Firebird 4 batch API
4
+ *
5
+ * The COPY FROM analogue: write parameter rows, they are flushed in
6
+ * chunks through statement.executeBatch (single prepared statement,
7
+ * protocol-level batching, BLOB values included). Backpressure is the
8
+ * Writable machinery itself: a write callback is held while a chunk
9
+ * is in flight.
10
+ *
11
+ ***************************************/
12
+
13
+ import { Writable } from 'stream';
14
+ import { fromCallback } from '../callback';
15
+ import { batchResultToError } from '../utils';
16
+
17
+ /**
18
+ * Build the Writable for Database.batchStream / Transaction.batchStream.
19
+ * With `ownsTransaction` (the Database form) the stream runs its own
20
+ * transaction: committed on finish, rolled back on error/destroy —
21
+ * all-or-nothing for the whole stream. The Transaction form leaves
22
+ * commit/rollback to the caller.
23
+ *
24
+ * Rows accumulate up to options.flushRows (default 1000) per
25
+ * executeBatch flush; the remaining executeBatch options (chunkSize,
26
+ * bufferSize, …) pass through. After 'finish', stream.recordCount and
27
+ * stream.affectedRows carry the totals.
28
+ */
29
+ function makeBatchStream(target: any, query: string, options: any, ownsTransaction: boolean): Writable {
30
+ options = options || {};
31
+ const flushRows = options.flushRows > 0 ? Math.floor(options.flushRows) : 1000;
32
+
33
+ const batchOptions = { ...options };
34
+ delete batchOptions.flushRows;
35
+ delete batchOptions.highWaterMark;
36
+
37
+ let transaction: any = null;
38
+ let statement: any = null;
39
+ let buffered: any[][] = [];
40
+
41
+ const init = async () => {
42
+ if (statement) {
43
+ return;
44
+ }
45
+ transaction = ownsTransaction ? await target.transactionAsync() : target;
46
+ statement = await fromCallback((cb) => transaction.newStatement(query, cb));
47
+ };
48
+
49
+ const flush = async () => {
50
+ if (!buffered.length) {
51
+ return;
52
+ }
53
+ await init();
54
+ const chunk = buffered;
55
+ buffered = [];
56
+ const result: any = await fromCallback((cb) =>
57
+ statement.executeBatch(transaction, chunk, cb, batchOptions));
58
+ if (!result.success) {
59
+ // the same all-or-nothing error shape database.executeBatch uses
60
+ throw batchResultToError(result);
61
+ }
62
+ (stream as any).recordCount += result.recordCount;
63
+ for (const count of result.updateCounts) {
64
+ (stream as any).affectedRows += count;
65
+ }
66
+ };
67
+
68
+ const cleanup = async (commit: boolean) => {
69
+ if (statement) {
70
+ const stmt = statement;
71
+ statement = null;
72
+ await new Promise<void>((resolve) => stmt.release(() => resolve()));
73
+ }
74
+ if (ownsTransaction && transaction) {
75
+ const tx = transaction;
76
+ transaction = null;
77
+ await (commit ? tx.commitAsync() : tx.rollbackAsync());
78
+ }
79
+ };
80
+
81
+ const stream = new Writable({
82
+ objectMode: true,
83
+ highWaterMark: options.highWaterMark > 0 ? options.highWaterMark : flushRows,
84
+
85
+ write(row: any, _enc: any, cb: (err?: any) => void) {
86
+ if (!Array.isArray(row)) {
87
+ cb(new Error('batchStream expects parameter-array rows'));
88
+ return;
89
+ }
90
+ buffered.push(row);
91
+ if (buffered.length >= flushRows) {
92
+ flush().then(() => cb(), cb);
93
+ } else {
94
+ cb();
95
+ }
96
+ },
97
+
98
+ final(cb: (err?: any) => void) {
99
+ // an empty stream finishes without touching the server at all
100
+ // (flush() early-returns and init never runs)
101
+ flush()
102
+ .then(() => cleanup(true))
103
+ .then(() => cb(), (err) => {
104
+ // the failed stream must not commit half a bulk load
105
+ cleanup(false).catch(() => { /* rollback best-effort */ });
106
+ cb(err);
107
+ });
108
+ },
109
+
110
+ destroy(err: any, cb: (err?: any) => void) {
111
+ cleanup(false)
112
+ .then(() => cb(err), () => cb(err));
113
+ },
114
+ });
115
+
116
+ (stream as any).recordCount = 0;
117
+ (stream as any).affectedRows = 0;
118
+ return stream;
119
+ }
120
+
121
+ export = makeBatchStream;