@leonardovida-md/drizzle-neo-duckdb 1.1.0 → 1.1.1

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
@@ -43,6 +43,14 @@ export function resolvePoolSize(
43
43
  export interface DuckDBConnectionPoolOptions {
44
44
  /** Maximum concurrent connections. Defaults to 4. */
45
45
  size?: number;
46
+ /** Timeout in milliseconds to wait for a connection. Defaults to 30000 (30s). */
47
+ acquireTimeout?: number;
48
+ /** Maximum number of requests waiting for a connection. Defaults to 100. */
49
+ maxWaitingRequests?: number;
50
+ /** Max time (ms) a connection may live before being recycled. */
51
+ maxLifetimeMs?: number;
52
+ /** Max idle time (ms) before an idle connection is discarded. */
53
+ idleTimeoutMs?: number;
46
54
  }
47
55
 
48
56
  export function createDuckDBConnectionPool(
@@ -50,49 +58,211 @@ export function createDuckDBConnectionPool(
50
58
  options: DuckDBConnectionPoolOptions = {}
51
59
  ): DuckDBConnectionPool & { size: number } {
52
60
  const size = options.size && options.size > 0 ? options.size : 4;
53
- const idle: DuckDBConnection[] = [];
54
- const waiting: Array<(conn: DuckDBConnection) => void> = [];
61
+ const acquireTimeout = options.acquireTimeout ?? 30_000;
62
+ const maxWaitingRequests = options.maxWaitingRequests ?? 100;
63
+ const maxLifetimeMs = options.maxLifetimeMs;
64
+ const idleTimeoutMs = options.idleTimeoutMs;
65
+ const metadata = new WeakMap<
66
+ DuckDBConnection,
67
+ { createdAt: number; lastUsedAt: number }
68
+ >();
69
+
70
+ type PooledConnection = {
71
+ connection: DuckDBConnection;
72
+ createdAt: number;
73
+ lastUsedAt: number;
74
+ };
75
+
76
+ const idle: PooledConnection[] = [];
77
+ const waiting: Array<{
78
+ resolve: (conn: DuckDBConnection) => void;
79
+ reject: (error: Error) => void;
80
+ timeoutId: ReturnType<typeof setTimeout>;
81
+ }> = [];
55
82
  let total = 0;
56
83
  let closed = false;
84
+ // Track pending acquires to handle race conditions during close
85
+ let pendingAcquires = 0;
86
+
87
+ const shouldRecycle = (conn: PooledConnection, now: number): boolean => {
88
+ if (maxLifetimeMs !== undefined && now - conn.createdAt >= maxLifetimeMs) {
89
+ return true;
90
+ }
91
+ if (idleTimeoutMs !== undefined && now - conn.lastUsedAt >= idleTimeoutMs) {
92
+ return true;
93
+ }
94
+ return false;
95
+ };
57
96
 
58
97
  const acquire = async (): Promise<DuckDBConnection> => {
59
98
  if (closed) {
60
99
  throw new Error('DuckDB connection pool is closed');
61
100
  }
62
101
 
63
- if (idle.length > 0) {
64
- return idle.pop() as DuckDBConnection;
102
+ while (idle.length > 0) {
103
+ const pooled = idle.pop() as PooledConnection;
104
+ const now = Date.now();
105
+ if (shouldRecycle(pooled, now)) {
106
+ await closeClientConnection(pooled.connection);
107
+ total = Math.max(0, total - 1);
108
+ metadata.delete(pooled.connection);
109
+ continue;
110
+ }
111
+ pooled.lastUsedAt = now;
112
+ metadata.set(pooled.connection, {
113
+ createdAt: pooled.createdAt,
114
+ lastUsedAt: pooled.lastUsedAt,
115
+ });
116
+ return pooled.connection;
65
117
  }
66
118
 
67
119
  if (total < size) {
120
+ pendingAcquires += 1;
68
121
  total += 1;
69
- return await DuckDBConnection.create(instance);
122
+ try {
123
+ const connection = await DuckDBConnection.create(instance);
124
+ // Check if pool was closed during async connection creation
125
+ if (closed) {
126
+ await closeClientConnection(connection);
127
+ total -= 1;
128
+ throw new Error('DuckDB connection pool is closed');
129
+ }
130
+ const now = Date.now();
131
+ metadata.set(connection, { createdAt: now, lastUsedAt: now });
132
+ return connection;
133
+ } catch (error) {
134
+ total -= 1;
135
+ throw error;
136
+ } finally {
137
+ pendingAcquires -= 1;
138
+ }
70
139
  }
71
140
 
72
- return await new Promise((resolve) => {
73
- waiting.push(resolve);
141
+ // Check queue limit before waiting
142
+ if (waiting.length >= maxWaitingRequests) {
143
+ throw new Error(
144
+ `DuckDB connection pool queue is full (max ${maxWaitingRequests} waiting requests)`
145
+ );
146
+ }
147
+
148
+ return await new Promise((resolve, reject) => {
149
+ const timeoutId = setTimeout(() => {
150
+ // Remove this waiter from the queue
151
+ const idx = waiting.findIndex((w) => w.timeoutId === timeoutId);
152
+ if (idx !== -1) {
153
+ waiting.splice(idx, 1);
154
+ }
155
+ reject(
156
+ new Error(
157
+ `DuckDB connection pool acquire timeout after ${acquireTimeout}ms`
158
+ )
159
+ );
160
+ }, acquireTimeout);
161
+
162
+ waiting.push({ resolve, reject, timeoutId });
74
163
  });
75
164
  };
76
165
 
77
166
  const release = async (connection: DuckDBConnection): Promise<void> => {
167
+ const waiter = waiting.shift();
168
+ if (waiter) {
169
+ clearTimeout(waiter.timeoutId);
170
+ const now = Date.now();
171
+ const meta =
172
+ metadata.get(connection) ??
173
+ ({ createdAt: now, lastUsedAt: now } as {
174
+ createdAt: number;
175
+ lastUsedAt: number;
176
+ });
177
+
178
+ const expired =
179
+ maxLifetimeMs !== undefined && now - meta.createdAt >= maxLifetimeMs;
180
+
181
+ if (closed) {
182
+ await closeClientConnection(connection);
183
+ total = Math.max(0, total - 1);
184
+ metadata.delete(connection);
185
+ waiter.reject(new Error('DuckDB connection pool is closed'));
186
+ return;
187
+ }
188
+
189
+ if (expired) {
190
+ await closeClientConnection(connection);
191
+ total = Math.max(0, total - 1);
192
+ metadata.delete(connection);
193
+ try {
194
+ const replacement = await acquire();
195
+ waiter.resolve(replacement);
196
+ } catch (error) {
197
+ waiter.reject(error as Error);
198
+ }
199
+ return;
200
+ }
201
+
202
+ meta.lastUsedAt = now;
203
+ metadata.set(connection, meta);
204
+ waiter.resolve(connection);
205
+ return;
206
+ }
207
+
78
208
  if (closed) {
79
209
  await closeClientConnection(connection);
210
+ metadata.delete(connection);
211
+ total = Math.max(0, total - 1);
80
212
  return;
81
213
  }
82
214
 
83
- const waiter = waiting.shift();
84
- if (waiter) {
85
- waiter(connection);
215
+ const now = Date.now();
216
+ const existingMeta =
217
+ metadata.get(connection) ??
218
+ ({ createdAt: now, lastUsedAt: now } as {
219
+ createdAt: number;
220
+ lastUsedAt: number;
221
+ });
222
+ existingMeta.lastUsedAt = now;
223
+ metadata.set(connection, existingMeta);
224
+
225
+ if (
226
+ maxLifetimeMs !== undefined &&
227
+ now - existingMeta.createdAt >= maxLifetimeMs
228
+ ) {
229
+ await closeClientConnection(connection);
230
+ total -= 1;
231
+ metadata.delete(connection);
86
232
  return;
87
233
  }
88
234
 
89
- idle.push(connection);
235
+ idle.push({
236
+ connection,
237
+ createdAt: existingMeta.createdAt,
238
+ lastUsedAt: existingMeta.lastUsedAt,
239
+ });
90
240
  };
91
241
 
92
242
  const close = async (): Promise<void> => {
93
243
  closed = true;
244
+
245
+ // Clear all waiting requests with their timeouts
246
+ const waiters = waiting.splice(0, waiting.length);
247
+ for (const waiter of waiters) {
248
+ clearTimeout(waiter.timeoutId);
249
+ waiter.reject(new Error('DuckDB connection pool is closed'));
250
+ }
251
+
252
+ // Close all idle connections (use allSettled to ensure all are attempted)
94
253
  const toClose = idle.splice(0, idle.length);
95
- await Promise.all(toClose.map((conn) => closeClientConnection(conn)));
254
+ await Promise.allSettled(
255
+ toClose.map((item) => closeClientConnection(item.connection))
256
+ );
257
+ total = Math.max(0, total - toClose.length);
258
+ toClose.forEach((item) => metadata.delete(item.connection));
259
+
260
+ // Wait for pending acquires to complete (with a reasonable timeout)
261
+ const maxWait = 5000;
262
+ const start = Date.now();
263
+ while (pendingAcquires > 0 && Date.now() - start < maxWait) {
264
+ await new Promise((r) => setTimeout(r, 10));
265
+ }
96
266
  };
97
267
 
98
268
  return {
package/src/session.ts CHANGED
@@ -17,8 +17,8 @@ import { fillPlaceholders, type Query, SQL, sql } from 'drizzle-orm/sql/sql';
17
17
  import type { Assume } from 'drizzle-orm/utils';
18
18
  import { adaptArrayOperators } from './sql/query-rewriters.ts';
19
19
  import { mapResultRow } from './sql/result-mapper.ts';
20
- import type { DuckDBDialect } from './dialect.ts';
21
20
  import { TransactionRollbackError } from 'drizzle-orm/errors';
21
+ import type { DuckDBDialect } from './dialect.ts';
22
22
  import type {
23
23
  DuckDBClientLike,
24
24
  DuckDBConnectionPool,
@@ -36,6 +36,16 @@ import type { DuckDBConnection } from '@duckdb/node-api';
36
36
 
37
37
  export type { DuckDBClientLike, RowData } from './client.ts';
38
38
 
39
+ function isSavepointSyntaxError(error: unknown): boolean {
40
+ if (!(error instanceof Error) || !error.message) {
41
+ return false;
42
+ }
43
+ return (
44
+ error.message.toLowerCase().includes('savepoint') &&
45
+ error.message.toLowerCase().includes('syntax error')
46
+ );
47
+ }
48
+
39
49
  export class DuckDBPreparedQuery<
40
50
  T extends PreparedQueryConfig,
41
51
  > extends PgPreparedQuery<T> {
@@ -131,6 +141,7 @@ export class DuckDBSession<
131
141
  private rewriteArrays: boolean;
132
142
  private rejectStringArrayLiterals: boolean;
133
143
  private hasWarnedArrayLiteral = false;
144
+ private rollbackOnly = false;
134
145
 
135
146
  constructor(
136
147
  private client: DuckDBClientLike,
@@ -168,8 +179,19 @@ export class DuckDBSession<
168
179
  );
169
180
  }
170
181
 
182
+ override execute<T>(query: SQL): Promise<T> {
183
+ this.dialect.resetPgJsonFlag();
184
+ return super.execute(query);
185
+ }
186
+
187
+ override all<T = unknown>(query: SQL): Promise<T[]> {
188
+ this.dialect.resetPgJsonFlag();
189
+ return super.all(query);
190
+ }
191
+
171
192
  override async transaction<T>(
172
- transaction: (tx: DuckDBTransaction<TFullSchema, TSchema>) => Promise<T>
193
+ transaction: (tx: DuckDBTransaction<TFullSchema, TSchema>) => Promise<T>,
194
+ config?: PgTransactionConfig
173
195
  ): Promise<T> {
174
196
  let pinnedConnection: DuckDBConnection | undefined;
175
197
  let pool: DuckDBConnectionPool | undefined;
@@ -197,8 +219,16 @@ export class DuckDBSession<
197
219
  try {
198
220
  await tx.execute(sql`BEGIN TRANSACTION;`);
199
221
 
222
+ if (config) {
223
+ await tx.setTransaction(config);
224
+ }
225
+
200
226
  try {
201
227
  const result = await transaction(tx);
228
+ if (session.isRollbackOnly()) {
229
+ await tx.execute(sql`rollback`);
230
+ throw new TransactionRollbackError();
231
+ }
202
232
  await tx.execute(sql`commit`);
203
233
  return result;
204
234
  } catch (error) {
@@ -227,7 +257,9 @@ export class DuckDBSession<
227
257
  query: SQL,
228
258
  options: ExecuteInBatchesOptions = {}
229
259
  ): AsyncGenerator<GenericRowData<T>[], void, void> {
260
+ this.dialect.resetPgJsonFlag();
230
261
  const builtQuery = this.dialect.sqlToQuery(query);
262
+ this.dialect.assertNoPgJsonColumns();
231
263
  const params = prepareParams(builtQuery.params, {
232
264
  rejectStringArrayLiterals: this.rejectStringArrayLiterals,
233
265
  warnOnStringArrayLiteral: this.rejectStringArrayLiterals
@@ -256,7 +288,9 @@ export class DuckDBSession<
256
288
  }
257
289
 
258
290
  async executeArrow(query: SQL): Promise<unknown> {
291
+ this.dialect.resetPgJsonFlag();
259
292
  const builtQuery = this.dialect.sqlToQuery(query);
293
+ this.dialect.assertNoPgJsonColumns();
260
294
  const params = prepareParams(builtQuery.params, {
261
295
  rejectStringArrayLiterals: this.rejectStringArrayLiterals,
262
296
  warnOnStringArrayLiteral: this.rejectStringArrayLiterals
@@ -278,6 +312,14 @@ export class DuckDBSession<
278
312
 
279
313
  return executeArrowOnClient(this.client, rewrittenQuery, params);
280
314
  }
315
+
316
+ markRollbackOnly(): void {
317
+ this.rollbackOnly = true;
318
+ }
319
+
320
+ isRollbackOnly(): boolean {
321
+ return this.rollbackOnly;
322
+ }
281
323
  }
282
324
 
283
325
  type PgTransactionInternals<
@@ -346,14 +388,65 @@ export class DuckDBTransaction<
346
388
  ): Promise<T> {
347
389
  // Cast needed: PgTransaction doesn't expose dialect/session properties in public API
348
390
  type Tx = DuckDBTransactionWithInternals<TFullSchema, TSchema>;
391
+ const internals = this as unknown as Tx;
392
+ const savepoint = `drizzle_savepoint_${this.nestedIndex + 1}`;
393
+ const savepointSql = sql.raw(`savepoint ${savepoint}`);
394
+ const releaseSql = sql.raw(`release savepoint ${savepoint}`);
395
+ const rollbackSql = sql.raw(`rollback to savepoint ${savepoint}`);
396
+
349
397
  const nestedTx = new DuckDBTransaction<TFullSchema, TSchema>(
350
- (this as unknown as Tx).dialect,
351
- (this as unknown as Tx).session,
398
+ internals.dialect,
399
+ internals.session,
352
400
  this.schema,
353
401
  this.nestedIndex + 1
354
402
  );
355
403
 
356
- return transaction(nestedTx);
404
+ // Check dialect-level savepoint support (per-instance, not global)
405
+ if (internals.dialect.areSavepointsUnsupported()) {
406
+ return this.runNestedWithoutSavepoint(transaction, nestedTx, internals);
407
+ }
408
+
409
+ let createdSavepoint = false;
410
+ try {
411
+ await internals.session.execute(savepointSql);
412
+ internals.dialect.markSavepointsSupported();
413
+ createdSavepoint = true;
414
+ } catch (error) {
415
+ if (!isSavepointSyntaxError(error)) {
416
+ throw error;
417
+ }
418
+ internals.dialect.markSavepointsUnsupported();
419
+ return this.runNestedWithoutSavepoint(transaction, nestedTx, internals);
420
+ }
421
+
422
+ try {
423
+ const result = await transaction(nestedTx);
424
+ if (createdSavepoint) {
425
+ await internals.session.execute(releaseSql);
426
+ }
427
+ return result;
428
+ } catch (error) {
429
+ if (createdSavepoint) {
430
+ await internals.session.execute(rollbackSql);
431
+ }
432
+ (
433
+ internals.session as DuckDBSession<TFullSchema, TSchema>
434
+ ).markRollbackOnly();
435
+ throw error;
436
+ }
437
+ }
438
+
439
+ private runNestedWithoutSavepoint<T>(
440
+ transaction: (tx: DuckDBTransaction<TFullSchema, TSchema>) => Promise<T>,
441
+ nestedTx: DuckDBTransaction<TFullSchema, TSchema>,
442
+ internals: DuckDBTransactionWithInternals<TFullSchema, TSchema>
443
+ ): Promise<T> {
444
+ return transaction(nestedTx).catch((error) => {
445
+ (
446
+ internals.session as DuckDBSession<TFullSchema, TSchema>
447
+ ).markRollbackOnly();
448
+ throw error;
449
+ });
357
450
  }
358
451
  }
359
452
 
@@ -1,96 +1,204 @@
1
- export function adaptArrayOperators(query: string): string {
2
- type ArrayOperator = {
3
- token: '@>' | '<@' | '&&';
4
- fn: 'array_has_all' | 'array_has_any';
5
- swap?: boolean;
6
- };
7
-
8
- const operators: ArrayOperator[] = [
9
- { token: '@>', fn: 'array_has_all' },
10
- { token: '<@', fn: 'array_has_all', swap: true },
11
- { token: '&&', fn: 'array_has_any' },
12
- ];
13
-
14
- const isWhitespace = (char: string | undefined) =>
15
- char !== undefined && /\s/.test(char);
16
-
17
- const walkLeft = (source: string, start: number): [number, string] => {
18
- let idx = start;
19
- while (idx >= 0 && isWhitespace(source[idx])) {
20
- idx--;
1
+ type ArrayOperator = {
2
+ token: '@>' | '<@' | '&&';
3
+ fn: 'array_has_all' | 'array_has_any';
4
+ swap?: boolean;
5
+ };
6
+
7
+ const OPERATORS: ArrayOperator[] = [
8
+ { token: '@>', fn: 'array_has_all' },
9
+ { token: '<@', fn: 'array_has_all', swap: true },
10
+ { token: '&&', fn: 'array_has_any' },
11
+ ];
12
+
13
+ const isWhitespace = (char: string | undefined) =>
14
+ char !== undefined && /\s/.test(char);
15
+
16
+ export function scrubForRewrite(query: string): string {
17
+ let scrubbed = '';
18
+ type State = 'code' | 'single' | 'double' | 'lineComment' | 'blockComment';
19
+ let state: State = 'code';
20
+
21
+ for (let i = 0; i < query.length; i += 1) {
22
+ const char = query[i]!;
23
+ const next = query[i + 1];
24
+
25
+ if (state === 'code') {
26
+ if (char === "'") {
27
+ scrubbed += "'";
28
+ state = 'single';
29
+ continue;
30
+ }
31
+ if (char === '"') {
32
+ scrubbed += '"';
33
+ state = 'double';
34
+ continue;
35
+ }
36
+ if (char === '-' && next === '-') {
37
+ scrubbed += ' ';
38
+ i += 1;
39
+ state = 'lineComment';
40
+ continue;
41
+ }
42
+ if (char === '/' && next === '*') {
43
+ scrubbed += ' ';
44
+ i += 1;
45
+ state = 'blockComment';
46
+ continue;
47
+ }
48
+
49
+ scrubbed += char;
50
+ continue;
21
51
  }
22
52
 
23
- let depth = 0;
24
- let inString = false;
25
- for (; idx >= 0; idx--) {
26
- const ch = source[idx];
27
- if (ch === undefined) break;
28
- if (ch === "'" && source[idx - 1] !== '\\') {
29
- inString = !inString;
53
+ if (state === 'single') {
54
+ if (char === "'" && next === "'") {
55
+ scrubbed += "''";
56
+ i += 1;
57
+ continue;
30
58
  }
31
- if (inString) continue;
32
- if (ch === ')' || ch === ']') {
33
- depth++;
34
- } else if (ch === '(' || ch === '[') {
35
- depth--;
36
- if (depth < 0) {
37
- return [idx + 1, source.slice(idx + 1, start + 1)];
38
- }
39
- } else if (depth === 0 && isWhitespace(ch)) {
40
- return [idx + 1, source.slice(idx + 1, start + 1)];
59
+ // Preserve quote for boundary detection but mask inner chars with a
60
+ // non-whitespace placeholder to avoid false positives on operators.
61
+ scrubbed += char === "'" ? "'" : '.';
62
+ if (char === "'") {
63
+ state = 'code';
64
+ }
65
+ continue;
66
+ }
67
+
68
+ if (state === 'double') {
69
+ if (char === '"' && next === '"') {
70
+ scrubbed += '""';
71
+ i += 1;
72
+ continue;
73
+ }
74
+ scrubbed += char === '"' ? '"' : '.';
75
+ if (char === '"') {
76
+ state = 'code';
77
+ }
78
+ continue;
79
+ }
80
+
81
+ if (state === 'lineComment') {
82
+ scrubbed += char === '\n' ? '\n' : ' ';
83
+ if (char === '\n') {
84
+ state = 'code';
85
+ }
86
+ continue;
87
+ }
88
+
89
+ if (state === 'blockComment') {
90
+ if (char === '*' && next === '/') {
91
+ scrubbed += ' ';
92
+ i += 1;
93
+ state = 'code';
94
+ } else {
95
+ scrubbed += ' ';
41
96
  }
42
97
  }
43
- return [0, source.slice(0, start + 1)];
44
- };
98
+ }
99
+
100
+ return scrubbed;
101
+ }
45
102
 
46
- const walkRight = (source: string, start: number): [number, string] => {
47
- let idx = start;
48
- while (idx < source.length && isWhitespace(source[idx])) {
49
- idx++;
103
+ function findNextOperator(
104
+ scrubbed: string,
105
+ start: number
106
+ ): { index: number; operator: ArrayOperator } | null {
107
+ for (let idx = start; idx < scrubbed.length; idx += 1) {
108
+ for (const operator of OPERATORS) {
109
+ if (scrubbed.startsWith(operator.token, idx)) {
110
+ return { index: idx, operator };
111
+ }
50
112
  }
113
+ }
114
+ return null;
115
+ }
51
116
 
52
- let depth = 0;
53
- let inString = false;
54
- for (; idx < source.length; idx++) {
55
- const ch = source[idx];
56
- if (ch === undefined) break;
57
- if (ch === "'" && source[idx - 1] !== '\\') {
58
- inString = !inString;
117
+ function walkLeft(
118
+ source: string,
119
+ scrubbed: string,
120
+ start: number
121
+ ): [number, string] {
122
+ let idx = start;
123
+ while (idx >= 0 && isWhitespace(scrubbed[idx])) {
124
+ idx -= 1;
125
+ }
126
+
127
+ let depth = 0;
128
+ for (; idx >= 0; idx -= 1) {
129
+ const ch = scrubbed[idx];
130
+ if (ch === ')' || ch === ']') {
131
+ depth += 1;
132
+ } else if (ch === '(' || ch === '[') {
133
+ if (depth === 0) {
134
+ return [idx + 1, source.slice(idx + 1, start + 1)];
59
135
  }
60
- if (inString) continue;
61
- if (ch === '(' || ch === '[') {
62
- depth++;
63
- } else if (ch === ')' || ch === ']') {
64
- depth--;
65
- if (depth < 0) {
66
- return [idx, source.slice(start, idx)];
67
- }
68
- } else if (depth === 0 && isWhitespace(ch)) {
136
+ depth = Math.max(0, depth - 1);
137
+ } else if (depth === 0 && isWhitespace(ch)) {
138
+ return [idx + 1, source.slice(idx + 1, start + 1)];
139
+ }
140
+ }
141
+
142
+ return [0, source.slice(0, start + 1)];
143
+ }
144
+
145
+ function walkRight(
146
+ source: string,
147
+ scrubbed: string,
148
+ start: number
149
+ ): [number, string] {
150
+ let idx = start;
151
+ while (idx < scrubbed.length && isWhitespace(scrubbed[idx])) {
152
+ idx += 1;
153
+ }
154
+
155
+ let depth = 0;
156
+ for (; idx < scrubbed.length; idx += 1) {
157
+ const ch = scrubbed[idx];
158
+ if (ch === '(' || ch === '[') {
159
+ depth += 1;
160
+ } else if (ch === ')' || ch === ']') {
161
+ if (depth === 0) {
69
162
  return [idx, source.slice(start, idx)];
70
163
  }
164
+ depth = Math.max(0, depth - 1);
165
+ } else if (depth === 0 && isWhitespace(ch)) {
166
+ return [idx, source.slice(start, idx)];
71
167
  }
72
- return [source.length, source.slice(start)];
73
- };
168
+ }
74
169
 
170
+ return [scrubbed.length, source.slice(start)];
171
+ }
172
+
173
+ export function adaptArrayOperators(query: string): string {
75
174
  let rewritten = query;
76
- for (const { token, fn, swap } of operators) {
77
- let idx = rewritten.indexOf(token);
78
- while (idx !== -1) {
79
- const [leftStart, leftExpr] = walkLeft(rewritten, idx - 1);
80
- const [rightEnd, rightExpr] = walkRight(rewritten, idx + token.length);
175
+ let scrubbed = scrubForRewrite(query);
176
+ let searchStart = 0;
81
177
 
82
- const left = leftExpr.trim();
83
- const right = rightExpr.trim();
178
+ // Re-run after each replacement to keep indexes aligned with the current string
179
+ while (true) {
180
+ const next = findNextOperator(scrubbed, searchStart);
181
+ if (!next) break;
84
182
 
85
- const replacement = `${fn}(${swap ? right : left}, ${
86
- swap ? left : right
87
- })`;
183
+ const { index, operator } = next;
184
+ const [leftStart, leftExpr] = walkLeft(rewritten, scrubbed, index - 1);
185
+ const [rightEnd, rightExpr] = walkRight(
186
+ rewritten,
187
+ scrubbed,
188
+ index + operator.token.length
189
+ );
88
190
 
89
- rewritten =
90
- rewritten.slice(0, leftStart) + replacement + rewritten.slice(rightEnd);
191
+ const left = leftExpr.trim();
192
+ const right = rightExpr.trim();
91
193
 
92
- idx = rewritten.indexOf(token, leftStart + replacement.length);
93
- }
194
+ const replacement = `${operator.fn}(${operator.swap ? right : left}, ${
195
+ operator.swap ? left : right
196
+ })`;
197
+
198
+ rewritten =
199
+ rewritten.slice(0, leftStart) + replacement + rewritten.slice(rightEnd);
200
+ scrubbed = scrubForRewrite(rewritten);
201
+ searchStart = leftStart + replacement.length;
94
202
  }
95
203
 
96
204
  return rewritten;