bunsane 0.2.8 → 0.2.10

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 (35) hide show
  1. package/CLAUDE.md +26 -0
  2. package/core/App.ts +97 -0
  3. package/core/remote/CircuitBreaker.ts +115 -0
  4. package/core/remote/OutboxWorker.ts +176 -0
  5. package/core/remote/RemoteManager.ts +400 -0
  6. package/core/remote/RpcCaller.ts +310 -0
  7. package/core/remote/StreamConsumer.ts +535 -0
  8. package/core/remote/decorators.ts +121 -0
  9. package/core/remote/health.ts +139 -0
  10. package/core/remote/index.ts +37 -0
  11. package/core/remote/metrics.ts +99 -0
  12. package/core/remote/outboxSchema.ts +41 -0
  13. package/core/remote/types.ts +151 -0
  14. package/core/scheduler/DistributedLock.ts +309 -266
  15. package/docs/SCALABILITY_PLAN.md +3 -3
  16. package/package.json +1 -1
  17. package/query/FilterBuilder.ts +25 -0
  18. package/query/Query.ts +5 -1
  19. package/query/builders/JsonbArrayBuilder.ts +116 -0
  20. package/query/index.ts +28 -2
  21. package/tests/helpers/MockRedisClient.ts +113 -0
  22. package/tests/helpers/MockRedisStreamServer.ts +448 -0
  23. package/tests/integration/query/Query.exec.test.ts +67 -14
  24. package/tests/integration/query/Query.jsonbArray.test.ts +214 -0
  25. package/tests/integration/remote/dlq.test.ts +175 -0
  26. package/tests/integration/remote/event-dispatch.test.ts +114 -0
  27. package/tests/integration/remote/outbox.test.ts +130 -0
  28. package/tests/integration/remote/rpc.test.ts +177 -0
  29. package/tests/pglite-setup.ts +1 -0
  30. package/tests/unit/query/JsonbArrayBuilder.test.ts +178 -0
  31. package/tests/unit/remote/CircuitBreaker.test.ts +159 -0
  32. package/tests/unit/remote/RemoteError.test.ts +55 -0
  33. package/tests/unit/remote/decorators.test.ts +195 -0
  34. package/tests/unit/remote/metrics.test.ts +115 -0
  35. package/tests/unit/remote/mockRedisStreamServer.test.ts +104 -0
@@ -1,266 +1,309 @@
1
- /**
2
- * Distributed Lock using PostgreSQL Advisory Locks
3
- *
4
- * PostgreSQL advisory locks are application-level locks that can be used
5
- * to coordinate between multiple application instances. They are:
6
- * - Session-based: automatically released when connection closes
7
- * - Non-blocking with pg_try_advisory_lock
8
- * - Perfect for distributed task scheduling
9
- *
10
- * @see https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS
11
- */
12
-
13
- import db from "../../database";
14
- import { logger } from "../Logger";
15
-
16
- const loggerInstance = logger.child({ scope: "DistributedLock" });
17
-
18
- /**
19
- * Result of a lock acquisition attempt
20
- */
21
- export interface LockResult {
22
- acquired: boolean;
23
- lockKey: bigint;
24
- taskId: string;
25
- }
26
-
27
- /**
28
- * Configuration for the distributed lock system
29
- */
30
- export interface DistributedLockConfig {
31
- /** Whether distributed locking is enabled */
32
- enabled: boolean;
33
- /** Prefix for lock keys to avoid collisions with other applications */
34
- lockKeyPrefix: number;
35
- /** Whether to log lock acquisition/release events */
36
- enableLogging: boolean;
37
- /** Timeout for lock acquisition attempts in milliseconds (0 = no retry) */
38
- lockTimeout: number;
39
- /** Retry interval when lockTimeout > 0 */
40
- retryInterval: number;
41
- }
42
-
43
- /**
44
- * Default configuration
45
- */
46
- export const DEFAULT_LOCK_CONFIG: DistributedLockConfig = {
47
- enabled: true,
48
- lockKeyPrefix: 0x42554E53, // "BUNS" in hex as a namespace prefix
49
- enableLogging: false,
50
- lockTimeout: 0, // No retry by default - skip if can't acquire
51
- retryInterval: 100,
52
- };
53
-
54
- /**
55
- * Distributed Lock Manager using PostgreSQL Advisory Locks
56
- *
57
- * Provides distributed coordination for scheduled tasks across multiple
58
- * application instances. Uses PostgreSQL's advisory lock system which
59
- * guarantees that only one instance can hold a lock at a time.
60
- *
61
- * Advisory locks are automatically released when:
62
- * - Explicitly unlocked via pg_advisory_unlock
63
- * - The database session ends
64
- * - The connection is closed
65
- */
66
- export class DistributedLock {
67
- private config: DistributedLockConfig;
68
- private heldLocks: Set<string> = new Set();
69
-
70
- constructor(config: Partial<DistributedLockConfig> = {}) {
71
- this.config = { ...DEFAULT_LOCK_CONFIG, ...config };
72
- }
73
-
74
- /**
75
- * Generate a consistent 64-bit lock key from a task ID
76
- * Uses a simple hash function to convert string task IDs to bigints
77
- *
78
- * The lock key is composed of:
79
- * - Upper 32 bits: lockKeyPrefix (namespace)
80
- * - Lower 32 bits: hash of taskId
81
- */
82
- private generateLockKey(taskId: string): bigint {
83
- // Simple hash function for the task ID
84
- let hash = 0;
85
- for (let i = 0; i < taskId.length; i++) {
86
- const char = taskId.charCodeAt(i);
87
- hash = ((hash << 5) - hash) + char;
88
- hash = hash & hash; // Convert to 32-bit integer
89
- }
90
- // Make it positive
91
- hash = Math.abs(hash);
92
-
93
- // Combine prefix (upper 32 bits) with hash (lower 32 bits)
94
- const prefix = BigInt(this.config.lockKeyPrefix);
95
- const hashBigInt = BigInt(hash >>> 0); // Ensure unsigned
96
- return (prefix << 32n) | hashBigInt;
97
- }
98
-
99
- /**
100
- * Try to acquire a distributed lock for a task
101
- *
102
- * Uses pg_try_advisory_lock which is non-blocking:
103
- * - Returns true immediately if lock is available
104
- * - Returns false immediately if lock is held by another session
105
- *
106
- * @param taskId The unique identifier for the task
107
- * @returns LockResult indicating whether the lock was acquired
108
- */
109
- async tryAcquire(taskId: string): Promise<LockResult> {
110
- if (!this.config.enabled) {
111
- return { acquired: true, lockKey: 0n, taskId };
112
- }
113
-
114
- const lockKey = this.generateLockKey(taskId);
115
- const startTime = Date.now();
116
-
117
- try {
118
- // Try to acquire the lock
119
- let acquired = await this.attemptLock(lockKey);
120
-
121
- // If lockTimeout > 0, retry until timeout
122
- if (!acquired && this.config.lockTimeout > 0) {
123
- while (!acquired && (Date.now() - startTime) < this.config.lockTimeout) {
124
- await this.sleep(this.config.retryInterval);
125
- acquired = await this.attemptLock(lockKey);
126
- }
127
- }
128
-
129
- if (acquired) {
130
- this.heldLocks.add(taskId);
131
- if (this.config.enableLogging) {
132
- loggerInstance.debug(`Acquired lock for task ${taskId} (key: ${lockKey})`);
133
- }
134
- } else {
135
- if (this.config.enableLogging) {
136
- loggerInstance.debug(`Failed to acquire lock for task ${taskId} (key: ${lockKey}) - another instance is executing`);
137
- }
138
- }
139
-
140
- return { acquired, lockKey, taskId };
141
- } catch (error) {
142
- loggerInstance.error(`Error acquiring lock for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
143
- // On error, return false to be safe (don't execute without lock)
144
- return { acquired: false, lockKey, taskId };
145
- }
146
- }
147
-
148
- /**
149
- * Attempt to acquire the PostgreSQL advisory lock
150
- */
151
- private async attemptLock(lockKey: bigint): Promise<boolean> {
152
- const result = await db`
153
- SELECT pg_try_advisory_lock(${lockKey}::bigint) as pg_try_advisory_lock
154
- `;
155
- return result[0]?.pg_try_advisory_lock ?? false;
156
- }
157
-
158
- /**
159
- * Release a distributed lock for a task
160
- *
161
- * Uses pg_advisory_unlock to explicitly release the lock.
162
- * The lock is also automatically released if the connection closes.
163
- *
164
- * @param taskId The unique identifier for the task
165
- * @returns true if the lock was released, false if it wasn't held
166
- */
167
- async release(taskId: string): Promise<boolean> {
168
- if (!this.config.enabled) {
169
- return true;
170
- }
171
-
172
- const lockKey = this.generateLockKey(taskId);
173
-
174
- try {
175
- const result = await db`
176
- SELECT pg_advisory_unlock(${lockKey}::bigint) as pg_advisory_unlock
177
- `;
178
-
179
- const released = result[0]?.pg_advisory_unlock ?? false;
180
-
181
- if (released) {
182
- this.heldLocks.delete(taskId);
183
- if (this.config.enableLogging) {
184
- loggerInstance.debug(`Released lock for task ${taskId} (key: ${lockKey})`);
185
- }
186
- } else {
187
- if (this.config.enableLogging) {
188
- loggerInstance.warn(`Lock for task ${taskId} was not held or already released`);
189
- }
190
- }
191
-
192
- return released;
193
- } catch (error) {
194
- loggerInstance.error(`Error releasing lock for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
195
- this.heldLocks.delete(taskId); // Remove from tracking even on error
196
- return false;
197
- }
198
- }
199
-
200
- /**
201
- * Release all locks held by this instance
202
- * Useful during shutdown
203
- */
204
- async releaseAll(): Promise<void> {
205
- const tasks = Array.from(this.heldLocks);
206
- for (const taskId of tasks) {
207
- await this.release(taskId);
208
- }
209
- }
210
-
211
- /**
212
- * Check if a lock is currently held (locally tracked)
213
- */
214
- isHeld(taskId: string): boolean {
215
- return this.heldLocks.has(taskId);
216
- }
217
-
218
- /**
219
- * Get the count of locks held by this instance
220
- */
221
- getHeldLockCount(): number {
222
- return this.heldLocks.size;
223
- }
224
-
225
- /**
226
- * Update the configuration
227
- */
228
- updateConfig(config: Partial<DistributedLockConfig>): void {
229
- this.config = { ...this.config, ...config };
230
- }
231
-
232
- /**
233
- * Get current configuration
234
- */
235
- getConfig(): DistributedLockConfig {
236
- return { ...this.config };
237
- }
238
-
239
- private sleep(ms: number): Promise<void> {
240
- return new Promise(resolve => setTimeout(resolve, ms));
241
- }
242
- }
243
-
244
- /**
245
- * Singleton instance for global access
246
- */
247
- let distributedLockInstance: DistributedLock | null = null;
248
-
249
- export function getDistributedLock(config?: Partial<DistributedLockConfig>): DistributedLock {
250
- if (!distributedLockInstance) {
251
- distributedLockInstance = new DistributedLock(config);
252
- } else if (config) {
253
- distributedLockInstance.updateConfig(config);
254
- }
255
- return distributedLockInstance;
256
- }
257
-
258
- /**
259
- * Reset the singleton instance (useful for testing)
260
- */
261
- export function resetDistributedLock(): void {
262
- if (distributedLockInstance) {
263
- distributedLockInstance.releaseAll().catch(() => {});
264
- distributedLockInstance = null;
265
- }
266
- }
1
+ /**
2
+ * Distributed Lock using PostgreSQL Advisory Locks
3
+ *
4
+ * PostgreSQL advisory locks are session-level (bound to the connection that
5
+ * acquired them). Bun's SQL pool hands out a different connection per query,
6
+ * so naively calling `pg_try_advisory_lock` on the pooled client leaves the
7
+ * lock stranded on whichever connection was used — `pg_advisory_unlock` on a
8
+ * different connection silently returns `false` and the lock is held until
9
+ * that connection eventually closes.
10
+ *
11
+ * Fix: reserve a dedicated connection via `sql.reserve()` once per instance
12
+ * and route every lock/unlock query through it. All locks owned by this
13
+ * instance live in one PostgreSQL session, so unlock always hits the session
14
+ * that acquired the lock. If the process crashes, PostgreSQL terminates the
15
+ * session and every held lock is released automatically — no cleanup needed.
16
+ *
17
+ * The reservation is lazy (acquired on first use) and released when either
18
+ * `releaseAll()` is called or no locks remain outstanding, so idle instances
19
+ * do not permanently consume a pool slot.
20
+ *
21
+ * @see https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS
22
+ */
23
+
24
+ import type { ReservedSQL } from "bun";
25
+ import db from "../../database";
26
+ import { logger } from "../Logger";
27
+
28
+ const loggerInstance = logger.child({ scope: "DistributedLock" });
29
+
30
+ export interface LockResult {
31
+ acquired: boolean;
32
+ lockKey: bigint;
33
+ taskId: string;
34
+ }
35
+
36
+ export interface DistributedLockConfig {
37
+ enabled: boolean;
38
+ lockKeyPrefix: number;
39
+ enableLogging: boolean;
40
+ /** Timeout for lock acquisition attempts in ms (0 = no retry) */
41
+ lockTimeout: number;
42
+ /** Retry interval when lockTimeout > 0 */
43
+ retryInterval: number;
44
+ }
45
+
46
+ export const DEFAULT_LOCK_CONFIG: DistributedLockConfig = {
47
+ enabled: true,
48
+ lockKeyPrefix: 0x42554E53, // "BUNS" in hex as a namespace prefix
49
+ enableLogging: false,
50
+ lockTimeout: 0,
51
+ retryInterval: 100,
52
+ };
53
+
54
+ export class DistributedLock {
55
+ private config: DistributedLockConfig;
56
+ private heldLocks: Set<string> = new Set();
57
+ private reservedConn: ReservedSQL | null = null;
58
+ private reservePromise: Promise<ReservedSQL> | null = null;
59
+
60
+ constructor(config: Partial<DistributedLockConfig> = {}) {
61
+ this.config = { ...DEFAULT_LOCK_CONFIG, ...config };
62
+ }
63
+
64
+ private generateLockKey(taskId: string): bigint {
65
+ let hash = 0;
66
+ for (let i = 0; i < taskId.length; i++) {
67
+ const char = taskId.charCodeAt(i);
68
+ hash = ((hash << 5) - hash) + char;
69
+ hash = hash & hash;
70
+ }
71
+ hash = Math.abs(hash);
72
+
73
+ const prefix = BigInt(this.config.lockKeyPrefix);
74
+ const hashBigInt = BigInt(hash >>> 0);
75
+ return (prefix << 32n) | hashBigInt;
76
+ }
77
+
78
+ /**
79
+ * Lazily reserve one dedicated connection that owns every advisory lock
80
+ * this instance takes. Concurrent callers share the same reservation via
81
+ * `reservePromise`.
82
+ */
83
+ private async ensureReserved(): Promise<ReservedSQL> {
84
+ if (this.reservedConn) return this.reservedConn;
85
+ if (!this.reservePromise) {
86
+ this.reservePromise = db.reserve().then((conn) => {
87
+ this.reservedConn = conn;
88
+ this.reservePromise = null;
89
+ return conn;
90
+ });
91
+ }
92
+ return this.reservePromise;
93
+ }
94
+
95
+ /**
96
+ * Release the pinned connection back to the pool. Only safe when no
97
+ * advisory locks are currently held on this instance — otherwise the
98
+ * session would be closed and locks forfeited.
99
+ */
100
+ private releaseReservation(): void {
101
+ if (!this.reservedConn) return;
102
+ try {
103
+ this.reservedConn.release();
104
+ } catch (error) {
105
+ loggerInstance.warn(
106
+ `Failed to release reserved connection: ${error instanceof Error ? error.message : String(error)}`
107
+ );
108
+ }
109
+ this.reservedConn = null;
110
+ }
111
+
112
+ /**
113
+ * Try to acquire a distributed lock for a task. Non-blocking when
114
+ * `lockTimeout` is 0 (default); retries every `retryInterval` ms up to
115
+ * `lockTimeout` otherwise.
116
+ */
117
+ async tryAcquire(taskId: string): Promise<LockResult> {
118
+ if (!this.config.enabled) {
119
+ return { acquired: true, lockKey: 0n, taskId };
120
+ }
121
+
122
+ const lockKey = this.generateLockKey(taskId);
123
+
124
+ if (this.heldLocks.has(taskId)) {
125
+ if (this.config.enableLogging) {
126
+ loggerInstance.debug(
127
+ `Lock for ${taskId} already held locally, returning acquired`
128
+ );
129
+ }
130
+ return { acquired: true, lockKey, taskId };
131
+ }
132
+
133
+ const startTime = Date.now();
134
+
135
+ try {
136
+ const conn = await this.ensureReserved();
137
+
138
+ let acquired = await this.attemptLock(conn, lockKey);
139
+
140
+ if (!acquired && this.config.lockTimeout > 0) {
141
+ while (
142
+ !acquired &&
143
+ Date.now() - startTime < this.config.lockTimeout
144
+ ) {
145
+ await this.sleep(this.config.retryInterval);
146
+ acquired = await this.attemptLock(conn, lockKey);
147
+ }
148
+ }
149
+
150
+ if (acquired) {
151
+ this.heldLocks.add(taskId);
152
+ if (this.config.enableLogging) {
153
+ loggerInstance.debug(
154
+ `Acquired lock for task ${taskId} (key: ${lockKey})`
155
+ );
156
+ }
157
+ return { acquired: true, lockKey, taskId };
158
+ }
159
+
160
+ // No locks taken on this attempt — if nothing else is held,
161
+ // return the reserved connection to the pool.
162
+ if (this.heldLocks.size === 0) {
163
+ this.releaseReservation();
164
+ }
165
+
166
+ if (this.config.enableLogging) {
167
+ loggerInstance.debug(
168
+ `Failed to acquire lock for task ${taskId} (key: ${lockKey}) — another instance is executing`
169
+ );
170
+ }
171
+ return { acquired: false, lockKey, taskId };
172
+ } catch (error) {
173
+ loggerInstance.error(
174
+ `Error acquiring lock for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`
175
+ );
176
+ if (this.heldLocks.size === 0) {
177
+ this.releaseReservation();
178
+ }
179
+ return { acquired: false, lockKey, taskId };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Release a single distributed lock. When the last lock is released the
185
+ * reserved connection is returned to the pool.
186
+ */
187
+ async release(taskId: string): Promise<boolean> {
188
+ if (!this.config.enabled) {
189
+ return true;
190
+ }
191
+
192
+ if (!this.heldLocks.has(taskId)) {
193
+ if (this.config.enableLogging) {
194
+ loggerInstance.warn(
195
+ `Lock for task ${taskId} was not held or already released`
196
+ );
197
+ }
198
+ return false;
199
+ }
200
+
201
+ const lockKey = this.generateLockKey(taskId);
202
+
203
+ if (!this.reservedConn) {
204
+ loggerInstance.warn(
205
+ `No reserved connection available for ${taskId}; dropping from heldLocks`
206
+ );
207
+ this.heldLocks.delete(taskId);
208
+ return false;
209
+ }
210
+
211
+ try {
212
+ const result = await this.reservedConn`
213
+ SELECT pg_advisory_unlock(${lockKey}::bigint) as pg_advisory_unlock
214
+ `;
215
+ const released = result[0]?.pg_advisory_unlock ?? false;
216
+
217
+ this.heldLocks.delete(taskId);
218
+
219
+ if (released && this.config.enableLogging) {
220
+ loggerInstance.debug(
221
+ `Released lock for task ${taskId} (key: ${lockKey})`
222
+ );
223
+ } else if (!released) {
224
+ loggerInstance.warn(
225
+ `pg_advisory_unlock returned false for task ${taskId} (key: ${lockKey})`
226
+ );
227
+ }
228
+
229
+ if (this.heldLocks.size === 0) {
230
+ this.releaseReservation();
231
+ }
232
+ return released;
233
+ } catch (error) {
234
+ loggerInstance.error(
235
+ `Error releasing lock for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`
236
+ );
237
+ this.heldLocks.delete(taskId);
238
+ if (this.heldLocks.size === 0) {
239
+ this.releaseReservation();
240
+ }
241
+ return false;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Release all held locks. Safe to call during shutdown.
247
+ */
248
+ async releaseAll(): Promise<void> {
249
+ const tasks = Array.from(this.heldLocks);
250
+ for (const taskId of tasks) {
251
+ await this.release(taskId);
252
+ }
253
+ // release() returns the reservation once heldLocks empties, but if
254
+ // nothing was held we still need to clean up any pending reservation.
255
+ if (this.heldLocks.size === 0) {
256
+ this.releaseReservation();
257
+ }
258
+ }
259
+
260
+ isHeld(taskId: string): boolean {
261
+ return this.heldLocks.has(taskId);
262
+ }
263
+
264
+ getHeldLockCount(): number {
265
+ return this.heldLocks.size;
266
+ }
267
+
268
+ updateConfig(config: Partial<DistributedLockConfig>): void {
269
+ this.config = { ...this.config, ...config };
270
+ }
271
+
272
+ getConfig(): DistributedLockConfig {
273
+ return { ...this.config };
274
+ }
275
+
276
+ private async attemptLock(
277
+ conn: ReservedSQL,
278
+ lockKey: bigint
279
+ ): Promise<boolean> {
280
+ const result = await conn`
281
+ SELECT pg_try_advisory_lock(${lockKey}::bigint) as pg_try_advisory_lock
282
+ `;
283
+ return result[0]?.pg_try_advisory_lock ?? false;
284
+ }
285
+
286
+ private sleep(ms: number): Promise<void> {
287
+ return new Promise((resolve) => setTimeout(resolve, ms));
288
+ }
289
+ }
290
+
291
+ let distributedLockInstance: DistributedLock | null = null;
292
+
293
+ export function getDistributedLock(
294
+ config?: Partial<DistributedLockConfig>
295
+ ): DistributedLock {
296
+ if (!distributedLockInstance) {
297
+ distributedLockInstance = new DistributedLock(config);
298
+ } else if (config) {
299
+ distributedLockInstance.updateConfig(config);
300
+ }
301
+ return distributedLockInstance;
302
+ }
303
+
304
+ export function resetDistributedLock(): void {
305
+ if (distributedLockInstance) {
306
+ distributedLockInstance.releaseAll().catch(() => {});
307
+ distributedLockInstance = null;
308
+ }
309
+ }
@@ -160,9 +160,9 @@ WHERE component_types @> ARRAY[$1, $2]::text[]
160
160
  ## Migration Strategy
161
161
 
162
162
  1. New indexes are additive (no breaking changes)
163
- 2. Query changes behind feature flag: `BUNSANE_QUERY_V2=true`
164
- 3. Gradual rollout with A/B testing on query performance
165
- 4. Deprecate old patterns after validation
163
+ 2. Query optimizations are **always on** (no feature flag needed)
164
+ 3. INTERSECT + scalar subquery patterns enabled by default since v0.2.7
165
+ 4. LATERAL joins disabled for INTERSECT queries to fix SQL generation bug (2026-03-14)
166
166
 
167
167
  ## Files to Modify
168
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
@@ -65,6 +65,31 @@ export function buildJSONPath(field: string, alias: string): string {
65
65
  }
66
66
  }
67
67
 
68
+ /**
69
+ * Build a JSON path expression that returns a JSONB node (not text)
70
+ *
71
+ * Unlike buildJSONPath which uses ->> (text extraction) at the leaf,
72
+ * this uses -> throughout, preserving the JSONB type. Required for
73
+ * JSONB operators like @>, <@, ?|, ?& that operate on JSONB values.
74
+ *
75
+ * @param field - The field path (e.g., "tags" or "metadata.tags")
76
+ * @param alias - The table alias (e.g., "c")
77
+ * @returns PostgreSQL JSONB path expression
78
+ *
79
+ * @example
80
+ * buildJSONBPath("tags", "c") // "c.data->'tags'"
81
+ * buildJSONBPath("metadata.tags", "c") // "c.data->'metadata'->'tags'"
82
+ */
83
+ export function buildJSONBPath(field: string, alias: string): string {
84
+ if (field.includes('.')) {
85
+ const parts = field.split('.');
86
+ const lastPart = parts.pop()!;
87
+ const nestedPath = parts.map(p => `'${p}'`).join('->');
88
+ return `${alias}.data->${nestedPath}->'${lastPart}'`;
89
+ }
90
+ return `${alias}.data->'${field}'`;
91
+ }
92
+
68
93
  /**
69
94
  * Compose multiple filter builders into a single builder that applies all conditions
70
95
  *
package/query/Query.ts CHANGED
@@ -25,7 +25,11 @@ export const FilterOp = {
25
25
  LIKE: "LIKE" as FilterOperator,
26
26
  ILIKE: "ILIKE" as FilterOperator,
27
27
  IN: "IN" as FilterOperator,
28
- NOT_IN: "NOT IN" as FilterOperator
28
+ NOT_IN: "NOT IN" as FilterOperator,
29
+ CONTAINS: "CONTAINS" as FilterOperator,
30
+ CONTAINED_BY: "CONTAINED_BY" as FilterOperator,
31
+ HAS_ANY: "HAS_ANY" as FilterOperator,
32
+ HAS_ALL: "HAS_ALL" as FilterOperator,
29
33
  }
30
34
 
31
35
  export interface QueryFilter {