node-firebird 2.11.0 → 2.13.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.
package/src/pool.ts CHANGED
@@ -26,7 +26,12 @@ type AttachFn = (options: any, callback: Callback) => void;
26
26
  *
27
27
  * Options: max (factory argument), options.min (floor the reaper never
28
28
  * shrinks below), options.idleTimeoutMillis (close idle connections after
29
- * this many ms; 0/absent = never), options.connectTimeout.
29
+ * this many ms; 0/absent = never), options.connectTimeout,
30
+ * options.maxUses (retire a connection after this many checkouts — pg's
31
+ * maxUses), options.maxLifetimeMillis (retire a connection this many ms
32
+ * after it was created — Postgres.js's max_lifetime). Retirement happens
33
+ * when the connection is returned to the pool, and the sweep also closes
34
+ * over-lifetime idle connections; a replacement is created on demand.
30
35
  */
31
36
  class Pool extends Events.EventEmitter {
32
37
  attach: AttachFn;
@@ -37,6 +42,8 @@ class Pool extends Events.EventEmitter {
37
42
  max: number;
38
43
  min: number;
39
44
  idleTimeoutMillis: number;
45
+ maxUses: number;
46
+ maxLifetimeMillis: number;
40
47
  pending: Callback[];
41
48
  options: any;
42
49
  _destroyed: boolean;
@@ -52,22 +59,50 @@ class Pool extends Events.EventEmitter {
52
59
  this.max = max || 4;
53
60
  this.min = (options && options.min > 0) ? Math.min(options.min, this.max) : 0;
54
61
  this.idleTimeoutMillis = (options && options.idleTimeoutMillis > 0) ? options.idleTimeoutMillis : 0;
62
+ this.maxUses = (options && options.maxUses > 0) ? options.maxUses : 0;
63
+ this.maxLifetimeMillis = (options && options.maxLifetimeMillis > 0) ? options.maxLifetimeMillis : 0;
55
64
  this.pending = []; // callbacks waiting for a free slot
56
65
  this.options = options;
57
66
  this._destroyed = false; // true after destroy() — prevents further use
58
67
  this._reaper = null;
59
68
 
60
- if (this.idleTimeoutMillis) {
69
+ // the sweep serves both idle eviction and lifetime retirement of
70
+ // idle connections; base its cadence on the tightest configured limit
71
+ var sweepBasis = Math.min(this.idleTimeoutMillis || Infinity, this.maxLifetimeMillis || Infinity);
72
+ if (sweepBasis !== Infinity) {
61
73
  var self = this;
62
- // Sweep at half the idle timeout (bounded to 100ms..30s) so a
63
- // connection lives at most ~1.5x idleTimeoutMillis. unref() keeps
74
+ // Sweep at half the basis (bounded to 100ms..30s) so a
75
+ // connection lives at most ~1.5x its limit. unref() keeps
64
76
  // the timer from holding the process open.
65
- var interval = Math.min(Math.max(this.idleTimeoutMillis / 2, 100), 30000);
77
+ var interval = Math.min(Math.max(sweepBasis / 2, 100), 30000);
66
78
  this._reaper = setInterval(function() { self._reap(); }, interval);
67
79
  if (this._reaper.unref) this._reaper.unref();
68
80
  }
69
81
  }
70
82
 
83
+ /** True when the connection exceeded maxUses / maxLifetimeMillis.
84
+ * Both stamps are set unconditionally when the pool creates the
85
+ * connection, so they can be read bare here. */
86
+ _isExpired(db: any): boolean {
87
+ if (this.maxUses > 0 && db.__poolUseCount >= this.maxUses) return true;
88
+ if (this.maxLifetimeMillis > 0 && Date.now() - db.__poolCreatedAt >= this.maxLifetimeMillis) return true;
89
+ return false;
90
+ }
91
+
92
+ /** Close a healthy pooled connection for good (reaper/retirement path). */
93
+ _retire(db: any): void {
94
+ var self = this;
95
+ this._forget(db);
96
+ db.connection._pooled = false;
97
+ try {
98
+ db.detach(function(err?: any) {
99
+ if (err) self._emitError(err, db);
100
+ });
101
+ } catch (e) {
102
+ self._emitError(e, db);
103
+ }
104
+ }
105
+
71
106
  /** Physical connections owned by the pool (idle + in use). */
72
107
  get totalCount(): number {
73
108
  return this.internaldb.length;
@@ -124,19 +159,18 @@ class Pool extends Events.EventEmitter {
124
159
  self._forget(db);
125
160
  return;
126
161
  }
162
+ // lifetime retirement applies even below min — recycling is the
163
+ // point; replacements are created on demand
164
+ if (self._isExpired(db)) {
165
+ self._retire(db);
166
+ return;
167
+ }
168
+ if (!self.idleTimeoutMillis) return;
127
169
  if (self.internaldb.length <= self.min) return;
128
170
  var idleSince = typeof db.__poolIdleSince === 'number' ? db.__poolIdleSince : now;
129
171
  if (now - idleSince < self.idleTimeoutMillis) return;
130
172
 
131
- self._forget(db);
132
- db.connection._pooled = false;
133
- try {
134
- db.detach(function(err?: any) {
135
- if (err) self._emitError(err, db);
136
- });
137
- } catch (e) {
138
- self._emitError(e, db);
139
- }
173
+ self._retire(db);
140
174
  });
141
175
  }
142
176
 
@@ -175,6 +209,7 @@ class Pool extends Events.EventEmitter {
175
209
  }
176
210
  // Idle connection available — hand it out immediately.
177
211
  self.dbinuse++;
212
+ db.__poolUseCount = (db.__poolUseCount || 0) + 1;
178
213
  self.emit('acquire', db);
179
214
  cb(null, db);
180
215
  } else {
@@ -235,6 +270,8 @@ class Pool extends Events.EventEmitter {
235
270
 
236
271
  if (!err) {
237
272
  self.dbinuse++;
273
+ db.__poolCreatedAt = Date.now();
274
+ db.__poolUseCount = 1;
238
275
  self.internaldb.push(db);
239
276
  db.on('detach', function () {
240
277
  // also in pool (could be a twice call to detach)
@@ -244,6 +281,12 @@ class Pool extends Events.EventEmitter {
244
281
  if (db.connection._isClosed || db.connection._isDetach || db.connection._pooled === false) {
245
282
  self.internaldb.splice(self.internaldb.indexOf(db), 1);
246
283
  self.emit('remove', db);
284
+ } else if (self._isExpired(db)) {
285
+ // worn out (maxUses / maxLifetimeMillis): close it
286
+ // for good instead of returning it to the idle
287
+ // pool. The re-fired detach event exits early via
288
+ // the internaldb guard above.
289
+ self._retire(db);
247
290
  } else {
248
291
  db.__poolIdleSince = Date.now();
249
292
  self.pooldb.push(db);
@@ -0,0 +1,196 @@
1
+ /***************************************
2
+ *
3
+ * Tagged-template query API (Postgres.js-style)
4
+ *
5
+ * db.sql`SELECT * FROM EMP WHERE ID = ${id}` → lazy thenable query
6
+ * db.sql('COLUMN NAME') → quoted identifier
7
+ *
8
+ * Interpolated values become positional `?` parameters — never string
9
+ * concatenation — so the API is injection-safe by construction. A query
10
+ * embedded inside another tag is treated as a fragment: its text and
11
+ * parameters are spliced in place. Arrays expand to `?, ?, ?` lists for
12
+ * IN clauses. Execution is lazy (on await/then) and happens exactly once.
13
+ *
14
+ ***************************************/
15
+
16
+ import type { QueryOptions, QueryResult } from './types';
17
+
18
+ /** Executor provided by Database/Transaction: runs text+params, resolves rows
19
+ * (or the full QueryResult when options.withMeta is set). */
20
+ export type SqlExecutor = (text: string, params: any[], options?: QueryOptions) => Promise<any>;
21
+
22
+ /** A dynamically quoted identifier produced by sql('name'). */
23
+ export class SqlIdentifier {
24
+ name: string;
25
+ constructor(name: string) {
26
+ this.name = name;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Quote a (possibly dot-qualified) identifier for dialect 3: each part is
32
+ * wrapped in double quotes with embedded quotes doubled, so user input can
33
+ * never break out of the identifier position.
34
+ */
35
+ export function quoteIdentifier(name: string): string {
36
+ return String(name)
37
+ .split('.')
38
+ .map((part) => '"' + part.replace(/"/g, '""') + '"')
39
+ .join('.');
40
+ }
41
+
42
+ /** Compiled form of a tagged query: SQL text with `?` placeholders + params. */
43
+ export interface CompiledQuery {
44
+ text: string;
45
+ params: any[];
46
+ }
47
+
48
+ function compile(strings: readonly string[], values: any[], active?: Set<any>): CompiledQuery {
49
+ let text = '';
50
+ const params: any[] = [];
51
+
52
+ for (let i = 0; i < strings.length; i++) {
53
+ text += strings[i];
54
+ if (i >= values.length) {
55
+ continue;
56
+ }
57
+ const value = values[i];
58
+
59
+ if (value instanceof SqlIdentifier) {
60
+ text += quoteIdentifier(value.name);
61
+ } else if (value instanceof SqlQuery) {
62
+ // embedded fragment: splice its text and params in place. The
63
+ // same fragment may appear several times (a DAG), but a fragment
64
+ // containing itself would recurse forever — track the expansion
65
+ // stack and reject cycles with a diagnosable error.
66
+ active = active || new Set();
67
+ if (active.has(value)) {
68
+ throw new Error('circular sql fragment: a query is embedded (transitively) inside itself');
69
+ }
70
+ active.add(value);
71
+ const inner = compile(value.strings, value.values, active);
72
+ active.delete(value);
73
+ text += inner.text;
74
+ params.push(...inner.params);
75
+ } else if (Array.isArray(value)) {
76
+ // IN (${[1, 2, 3]}) → IN (?, ?, ?)
77
+ if (!value.length) {
78
+ // '' would compile to `IN ()` — invalid SQL raising a server
79
+ // syntax error the caller never wrote; fail early instead
80
+ throw new Error('cannot interpolate an empty array (would compile to invalid SQL like "IN ()")');
81
+ }
82
+ text += value.map(() => '?').join(', ');
83
+ params.push(...value);
84
+ } else {
85
+ text += '?';
86
+ params.push(value);
87
+ }
88
+ }
89
+
90
+ return { text, params };
91
+ }
92
+
93
+ /**
94
+ * A lazily executed tagged query. Awaiting it (or calling then/catch/
95
+ * finally) runs it through the owning Database/Transaction exactly once;
96
+ * embedding it in another tag uses it as a fragment instead and never
97
+ * executes it.
98
+ */
99
+ export class SqlQuery<T = any> implements PromiseLike<T[]> {
100
+ readonly strings: readonly string[];
101
+ readonly values: any[];
102
+ private executor: SqlExecutor;
103
+ private queryOptions?: QueryOptions;
104
+ private executed?: Promise<any>;
105
+ private executedMeta?: boolean;
106
+
107
+ constructor(executor: SqlExecutor, strings: readonly string[], values: any[]) {
108
+ this.executor = executor;
109
+ this.strings = strings;
110
+ this.values = values;
111
+ }
112
+
113
+ /** The compiled SQL text (`?` placeholders) and parameter array. */
114
+ toQuery(): CompiledQuery {
115
+ return compile(this.strings, this.values);
116
+ }
117
+
118
+ /**
119
+ * Attach per-query options (timeout, signal, nestTables, …). Must be
120
+ * called before the query executes — options attached afterwards would
121
+ * be silently ignored, so that throws instead.
122
+ */
123
+ options(queryOptions: QueryOptions): this {
124
+ if (this.executed) {
125
+ throw new Error('sql query already executed — call .options() before awaiting it');
126
+ }
127
+ this.queryOptions = { ...this.queryOptions, ...queryOptions };
128
+ return this;
129
+ }
130
+
131
+ /** Execute resolving the full { rows, fields, affectedRows, … } result. */
132
+ withMeta(): Promise<QueryResult<T>> {
133
+ return this.run(true);
134
+ }
135
+
136
+ /**
137
+ * A query executes exactly once, in the shape of its first consumer
138
+ * (plain rows via then/await, or the full result via withMeta).
139
+ * Consuming it again in the OTHER shape cannot be honoured from the
140
+ * cached promise, so it throws rather than silently returning the
141
+ * wrong shape.
142
+ */
143
+ private run(withMeta: boolean): Promise<any> {
144
+ if (this.executed) {
145
+ if (withMeta !== this.executedMeta) {
146
+ throw new Error(this.executedMeta
147
+ ? 'sql query already executed via .withMeta() — await that result instead of the query'
148
+ : 'sql query already executed as plain rows — call .withMeta() first, or build a new query');
149
+ }
150
+ return this.executed;
151
+ }
152
+ this.executedMeta = withMeta;
153
+ const { text, params } = compile(this.strings, this.values);
154
+ const options = withMeta ? { ...this.queryOptions, withMeta: true } : this.queryOptions;
155
+ this.executed = this.executor(text, params, options);
156
+ return this.executed;
157
+ }
158
+
159
+ then<R1 = T[], R2 = never>(
160
+ onfulfilled?: ((value: T[]) => R1 | PromiseLike<R1>) | null,
161
+ onrejected?: ((reason: any) => R2 | PromiseLike<R2>) | null
162
+ ): Promise<R1 | R2> {
163
+ return this.run(false).then(onfulfilled, onrejected);
164
+ }
165
+
166
+ catch<R = never>(onrejected?: ((reason: any) => R | PromiseLike<R>) | null): Promise<T[] | R> {
167
+ return this.then(undefined, onrejected);
168
+ }
169
+
170
+ finally(onfinally?: (() => void) | null): Promise<T[]> {
171
+ return this.run(false).finally(onfinally) as Promise<T[]>;
172
+ }
173
+ }
174
+
175
+ /** The dual-use tag: template tag executes, string call quotes an identifier. */
176
+ export interface SqlTag {
177
+ <T = any>(strings: TemplateStringsArray, ...values: any[]): SqlQuery<T>;
178
+ (identifier: string): SqlIdentifier;
179
+ }
180
+
181
+ /**
182
+ * Build the `sql` tag for a Database/Transaction. `executor` receives the
183
+ * compiled text, params and per-query options and must return a promise
184
+ * (Database/Transaction pass their queryAsync).
185
+ */
186
+ export function makeSqlTag(executor: SqlExecutor): SqlTag {
187
+ return function sql(first: any, ...values: any[]): any {
188
+ if (Array.isArray(first) && Object.prototype.hasOwnProperty.call(first, 'raw')) {
189
+ return new SqlQuery(executor, first, values);
190
+ }
191
+ if (typeof first === 'string') {
192
+ return new SqlIdentifier(first);
193
+ }
194
+ throw new Error('sql must be used as a template tag (sql`...`) or called with an identifier string (sql(\'NAME\'))');
195
+ } as SqlTag;
196
+ }
package/src/types.ts CHANGED
@@ -5,7 +5,10 @@
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
+ import type { SqlTag } from './sql-template';
10
+
11
+ export type { SqlTag, SqlQuery, SqlIdentifier, CompiledQuery } from './sql-template';
9
12
 
10
13
  export type DatabaseCallback = (err: any, db: Database) => void;
11
14
  export type TransactionCallback = (err: any, transaction: Transaction) => void;
@@ -139,6 +142,89 @@ export type QueryOptions = {
139
142
  * unaffected.
140
143
  */
141
144
  nestTables?: boolean | string;
145
+ /**
146
+ * Per-query override of the `transformKeys` connection option: rewrite
147
+ * object-row keys — `'camel'` turns `FIRST_NAME` into `firstName`, or
148
+ * pass a custom `(key) => key` mapper. Applied after `lowercase_keys`
149
+ * and to both parts of `nestTables` keys. Column metadata (`fields`,
150
+ * typeCast) keeps the raw server aliases.
151
+ */
152
+ transformKeys?: 'camel' | ((key: string) => string);
153
+ /**
154
+ * Deliver a full result object `{ rows, fields, affectedRows,
155
+ * recordCounts, warnings }` instead of the bare rows (callback and
156
+ * promise APIs). For DML, `affectedRows` is what the server actually
157
+ * changed (`isc_info_sql_records`, one extra lightweight info request
158
+ * per statement — hence opt-in) and `recordCounts` breaks it down per
159
+ * verb; for SELECT it is the number of rows returned (pg's `rowCount`
160
+ * convention) with no extra round-trip. `warnings` carries any
161
+ * `isc_arg_warning` entries from the execute response. Honoured by
162
+ * query/execute and their *Async wrappers only — ignored by the
163
+ * streaming APIs (sequentially/queryStream, where rows bypass the
164
+ * result) and executeBatch (which has its own completion shape).
165
+ */
166
+ withMeta?: boolean;
167
+ }
168
+
169
+ /** Column metadata delivered in withMeta results (`fields`) — the same
170
+ * vocabulary the typeCast hook receives, plus nullable and the relation
171
+ * alias/schema. */
172
+ export interface FieldMetadata {
173
+ type: number;
174
+ typeName: string;
175
+ subType?: number;
176
+ scale?: number;
177
+ length?: number;
178
+ nullable?: boolean;
179
+ field?: string;
180
+ relation?: string;
181
+ relationAlias?: string;
182
+ relationSchema?: string;
183
+ alias?: string;
184
+ }
185
+
186
+ /** Per-verb server row counts of an executed DML statement. */
187
+ export interface RecordCounts {
188
+ selectCount: number;
189
+ insertCount: number;
190
+ updateCount: number;
191
+ deleteCount: number;
192
+ }
193
+
194
+ /** An isc_arg_warning entry from a server response ('warning' driver event
195
+ * and withMeta `warnings`). */
196
+ export interface ServerWarning {
197
+ gdscode: number;
198
+ params?: (string | number)[];
199
+ message: string;
200
+ }
201
+
202
+ /** Full result shape delivered when `withMeta: true` is set. */
203
+ export interface QueryResult<T = any> {
204
+ /** Rows array (SELECT), single row object (RETURNING / procedures), or undefined (plain DML). */
205
+ rows: T[] | T | undefined;
206
+ fields: FieldMetadata[];
207
+ /** DML: rows the server changed; SELECT: rows returned. */
208
+ affectedRows: number;
209
+ /** Set for DML statements only. */
210
+ recordCounts?: RecordCounts;
211
+ warnings: ServerWarning[];
212
+ }
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;
142
228
  }
143
229
 
144
230
  export type QueryStreamOptions = QueryOptions & {
@@ -152,11 +238,18 @@ export type QueryStreamOptions = QueryOptions & {
152
238
  }
153
239
 
154
240
  export interface Database {
241
+ /**
242
+ * Tagged-template query API (Postgres.js-style): interpolated values
243
+ * become positional parameters, `sql('NAME')` quotes an identifier,
244
+ * embedded `sql` fragments compose, arrays expand to `?, ?, ?` lists.
245
+ * The returned query is a lazy thenable — it executes once, on await.
246
+ */
247
+ sql: SqlTag;
155
248
  detach(callback?: SimpleCallback): Database;
156
249
  transaction(options: TransactionOptions|Isolation|TransactionCallback, callback?: TransactionCallback): Database;
157
250
  newStatement(query: string, callback: (err: Error | null, statement: Statement) => void): Database;
158
- query(query: string, params: QueryParams, callback: QueryCallback, options?: QueryOptions): Database;
159
- 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;
160
253
  /** Bulk-execute in its own transaction, all-or-nothing (Firebird 4.0+). */
161
254
  executeBatch(query: string, rows: QueryParams[], callback?: (err: any, result: BatchResult) => void, options?: BatchOptions): Database;
162
255
  sequentially(query: string, params: QueryParams, rowCallback: SequentialCallback, callback: SimpleCallback, options?: QueryOptions | boolean): Database;
@@ -167,6 +260,13 @@ export interface Database {
167
260
  * fetch and releases the statement.
168
261
  */
169
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;
170
270
  drop(callback: SimpleCallback): void;
171
271
  escape(value: any): string;
172
272
  attachEvent(callback: any): this;
@@ -176,8 +276,11 @@ export interface Database {
176
276
  createSchema(schemaName: string, tablespaceName?: string | QueryCallback, callback?: QueryCallback): Database;
177
277
 
178
278
  // Promise / async-await API (see README § Promises / async–await).
179
- // Result metadata is only available through the callback API.
279
+ // Pass { withMeta: true } to resolve with the full QueryResult
280
+ // (rows + fields + affectedRows + warnings) instead of bare rows.
281
+ queryAsync<T = any>(query: string, params: QueryParams | undefined, options: QueryOptions & { withMeta: true }): Promise<QueryResult<T>>;
180
282
  queryAsync<T = any>(query: string, params?: QueryParams, options?: QueryOptions): Promise<T[]>;
283
+ executeAsync<T = any>(query: string, params: QueryParams | undefined, options: QueryOptions & { withMeta: true }): Promise<QueryResult<T>>;
181
284
  executeAsync<T = any>(query: string, params?: QueryParams, options?: QueryOptions): Promise<T[]>;
182
285
  executeBatchAsync(query: string, rows: QueryParams[], options?: BatchOptions): Promise<BatchResult>;
183
286
  sequentiallyAsync(query: string, params: QueryParams | undefined, rowCallback: SequentialCallback, options?: QueryOptions | boolean): Promise<void>;
@@ -201,9 +304,17 @@ export interface Database {
201
304
  }
202
305
 
203
306
  export interface Transaction {
307
+ /** Tagged-template query API running inside this transaction (see Database.sql). */
308
+ sql: SqlTag;
309
+ /**
310
+ * Run `work` inside a savepoint: released on resolve, rolled back TO
311
+ * (undoing only work's changes) on reject — the transaction stays
312
+ * usable either way. Nestable.
313
+ */
314
+ savepoint<T>(work: (transaction: Transaction) => Promise<T> | T): Promise<T>;
204
315
  newStatement(query: string, callback: (err: Error | null, statement: Statement) => void): void;
205
- query(query: string, params: QueryParams, callback: QueryCallback, options?: QueryOptions): void;
206
- 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;
207
318
  /** Bulk-execute within this transaction; per-record failures do not roll back (Firebird 4.0+). */
208
319
  executeBatch(query: string, rows: QueryParams[], callback?: (err: any, result: BatchResult) => void, options?: BatchOptions): void;
209
320
  sequentially(query: string, params: QueryParams, rowCallback: SequentialCallback, callback: SimpleCallback, options?: QueryOptions | boolean): Database;
@@ -213,13 +324,20 @@ export interface Transaction {
213
324
  * transaction is NOT committed when the stream ends.
214
325
  */
215
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;
216
332
  commit(callback?: SimpleCallback): void;
217
333
  commitRetaining(callback?: SimpleCallback): void;
218
334
  rollback(callback?: SimpleCallback): void;
219
335
  rollbackRetaining(callback?: SimpleCallback): void;
220
336
 
221
337
  // Promise / async-await API
338
+ queryAsync<T = any>(query: string, params: QueryParams | undefined, options: QueryOptions & { withMeta: true }): Promise<QueryResult<T>>;
222
339
  queryAsync<T = any>(query: string, params?: QueryParams, options?: QueryOptions): Promise<T[]>;
340
+ executeAsync<T = any>(query: string, params: QueryParams | undefined, options: QueryOptions & { withMeta: true }): Promise<QueryResult<T>>;
223
341
  executeAsync<T = any>(query: string, params?: QueryParams, options?: QueryOptions): Promise<T[]>;
224
342
  executeBatchAsync(query: string, rows: QueryParams[], options?: BatchOptions): Promise<BatchResult>;
225
343
  sequentiallyAsync(query: string, params: QueryParams | undefined, rowCallback: SequentialCallback, options?: QueryOptions | boolean): Promise<void>;
@@ -326,6 +444,13 @@ export interface Options {
326
444
  * per-query `namedPlaceholders: false` override.
327
445
  */
328
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;
329
454
  /**
330
455
  * Qualify object-row keys by source table (same option as mysql2), so
331
456
  * JOINed columns with the same name stop overwriting each other:
@@ -336,6 +461,14 @@ export interface Options {
336
461
  * array rows (execute) are unaffected. Overridable per query.
337
462
  */
338
463
  nestTables?: boolean | string;
464
+ /**
465
+ * Rewrite object-row keys (Postgres.js `transform` counterpart):
466
+ * `'camel'` turns `FIRST_NAME` into `firstName`, or pass a custom
467
+ * `(key) => key` mapper. Applied after `lowercase_keys` and to both
468
+ * parts of `nestTables` keys; column metadata keeps raw aliases.
469
+ * Overridable per query.
470
+ */
471
+ transformKeys?: 'camel' | ((key: string) => string);
339
472
  /**
340
473
  * TCP keepalive probing to detect dead/stale connections (same option
341
474
  * names as mysql2). On by default; set false to disable.
@@ -374,6 +507,20 @@ export interface Options {
374
507
  * Default 0 (idle connections are kept forever).
375
508
  */
376
509
  idleTimeoutMillis?: number;
510
+ /**
511
+ * Pool only: retire a physical connection after this many checkouts
512
+ * (pg's `maxUses`) — it is closed for good when returned to the pool
513
+ * and replaced on demand. Bounds server-side resource drift on
514
+ * long-lived connections. Default 0 (unlimited uses).
515
+ */
516
+ maxUses?: number;
517
+ /**
518
+ * Pool only: retire a physical connection this many milliseconds after
519
+ * it was created (Postgres.js's `max_lifetime`), on return to the pool
520
+ * or by the idle sweep — even below `min`; replacements are created on
521
+ * demand. Default 0 (unlimited lifetime).
522
+ */
523
+ maxLifetimeMillis?: number;
377
524
  /**
378
525
  * **Firebird 6.0+ only (Protocol 20+)**
379
526
  *
package/src/uri.ts CHANGED
@@ -198,7 +198,59 @@ export function parseConnectionString(str: string): Options {
198
198
  */
199
199
  export function normalizeOptions<T>(options: T | string): T {
200
200
  if (typeof options === 'string') {
201
- return parseConnectionString(options) as T;
201
+ options = parseConnectionString(options) as T;
202
202
  }
203
- return options;
203
+ return applyEnvDefaults(options as any);
204
+ }
205
+
206
+ /**
207
+ * Fall back to environment variables for connection settings the caller
208
+ * did not provide — the pg-style convention using Firebird's own names:
209
+ * ISC_USER / ISC_PASSWORD (honoured by isql and every official tool) plus
210
+ * FIREBIRD_HOST / FIREBIRD_PORT / FIREBIRD_DATABASE / FIREBIRD_ROLE.
211
+ * Explicit options always win; the driver's built-in defaults (SYSDBA /
212
+ * masterkey / 127.0.0.1) still apply when neither is set. A fresh object
213
+ * is returned so caller-owned options objects are never mutated.
214
+ */
215
+ const ENV_FALLBACKS: [string, string][] = [
216
+ ['user', 'ISC_USER'],
217
+ ['password', 'ISC_PASSWORD'],
218
+ ['host', 'FIREBIRD_HOST'],
219
+ ['port', 'FIREBIRD_PORT'],
220
+ ['database', 'FIREBIRD_DATABASE'],
221
+ ['role', 'FIREBIRD_ROLE'],
222
+ ];
223
+
224
+ function applyEnvDefaults<T extends Record<string, any>>(options: T): T {
225
+ let out: any = options;
226
+ for (const [key, envName] of ENV_FALLBACKS) {
227
+ const value = process.env[envName];
228
+ // empty-string env vars (common in CI: `export ISC_PASSWORD=`)
229
+ // count as unset
230
+ if (value === undefined || value === '') {
231
+ continue;
232
+ }
233
+ // a service-manager connection's `database` selects the TARGET of
234
+ // backup/restore — never let a leftover env var pick that silently
235
+ if (key === 'database' && (options as any).manager) {
236
+ continue;
237
+ }
238
+ if (out[key] === undefined || out[key] === null || out[key] === '') {
239
+ if (out === options) {
240
+ out = { ...(options as any) };
241
+ }
242
+ if (key === 'port') {
243
+ const port = Number(value);
244
+ if (!Number.isFinite(port) || port <= 0) {
245
+ // NaN is falsy: it would silently fall back to 3050
246
+ // downstream instead of surfacing the typo
247
+ throw new Error('Invalid FIREBIRD_PORT environment variable: ' + value);
248
+ }
249
+ out[key] = port;
250
+ } else {
251
+ out[key] = value;
252
+ }
253
+ }
254
+ }
255
+ return out;
204
256
  }
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)