bunsane 0.5.2 → 0.5.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to bunsane are documented here.
4
4
 
5
+ ## 0.5.2 — 2026-06-19
6
+
7
+ ### Added
8
+
9
+ - **`withLock(key, fn, options?)`** — public distributed-lock primitive,
10
+ exported from `bunsane/core`. Runs `fn` while holding a PostgreSQL advisory
11
+ lock and always releases it (even if `fn` throws); only one holder of a given
12
+ `key` runs `fn` at a time across every process pointed at the same database.
13
+ Returns `{ acquired: true, result }`, or `{ acquired: false }` when the lock
14
+ is held elsewhere (`fn` does not run). Wraps the same `DistributedLock`
15
+ singleton and PostgreSQL session the scheduler uses for task exclusion, now
16
+ surfaced for app-level "run once cluster-wide" work — reindex, migration,
17
+ cache rebuild. `options.wait` (ms, default `0` = try once) blocks for the lock
18
+ instead of skipping; `options.retryInterval` (default 100 ms) sets the poll
19
+ cadence. Layers an in-process guard over the advisory lock because PostgreSQL
20
+ advisory locks are reentrant per session — without it, two concurrent
21
+ same-key callers in one process would both win. Not reentrant; crash-safe
22
+ (session-scoped); honors `distributedLocking: false` (then always reports
23
+ `acquired: true` with no real lock). Also re-exported from
24
+ `bunsane/core/scheduler`. A new `core/index.ts` barrel establishes
25
+ `bunsane/core` as a public entry point.
26
+
5
27
  ## 0.5.1 — 2026-06-16
6
28
 
7
29
  ### Added
@@ -198,16 +198,45 @@ export async function handleRequest(app: any, req: Request): Promise<Response> {
198
198
 
199
199
  for (const [route, folder] of app.staticAssets) {
200
200
  if (url.pathname.startsWith(route)) {
201
- const relativePath = url.pathname.slice(route.length);
202
- const filePath = path.join(folder, relativePath);
201
+ const rawRelative = url.pathname.slice(route.length);
202
+ // Decode percent-encoding first so encoded traversal sequences
203
+ // (e.g. %2e%2e%2f) can't slip past the containment check.
204
+ let decodedRelative: string;
203
205
  try {
204
- const file = Bun.file(filePath);
206
+ decodedRelative = decodeURIComponent(rawRelative);
207
+ } catch {
208
+ clearTimeout(timeoutId);
209
+ return wrap(new Response("Bad request", {
210
+ status: 400,
211
+ headers: { "Content-Type": "text/plain" },
212
+ }));
213
+ }
214
+ // Resolve absolutely and confirm the target stays inside the
215
+ // served folder — blocks path traversal (../) out of the dir.
216
+ // Strip leading slashes so path.resolve treats the request as
217
+ // relative to the folder (an absolute-looking arg would reset
218
+ // to the filesystem root and bypass containment).
219
+ const relForResolve = decodedRelative.replace(/^[/\\]+/, "");
220
+ const resolvedBase = path.resolve(folder);
221
+ const resolvedFile = path.resolve(resolvedBase, relForResolve);
222
+ if (
223
+ resolvedFile !== resolvedBase &&
224
+ !resolvedFile.startsWith(resolvedBase + path.sep)
225
+ ) {
226
+ clearTimeout(timeoutId);
227
+ return wrap(new Response("Forbidden", {
228
+ status: 403,
229
+ headers: { "Content-Type": "text/plain" },
230
+ }));
231
+ }
232
+ try {
233
+ const file = Bun.file(resolvedFile);
205
234
  if (await file.exists()) {
206
235
  clearTimeout(timeoutId);
207
236
  return wrap(new Response(file));
208
237
  }
209
238
  } catch (error) {
210
- logger.error(`Error serving static file ${filePath}:`, error as any);
239
+ logger.error(`Error serving static file ${resolvedFile}:`, error as any);
211
240
  }
212
241
  }
213
242
  }
package/core/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Public `bunsane/core` entry point.
3
+ *
4
+ * Subpath imports (`bunsane/core/App`, `bunsane/core/middleware`, …) remain the
5
+ * primary surface; this barrel re-exports cross-cutting primitives intended to
6
+ * be imported as `bunsane/core`.
7
+ */
8
+
9
+ export {
10
+ withLock,
11
+ type WithLockOptions,
12
+ type LockOutcome,
13
+ DistributedLock,
14
+ getDistributedLock,
15
+ resetDistributedLock,
16
+ DEFAULT_LOCK_CONFIG,
17
+ type DistributedLockConfig,
18
+ type LockResult,
19
+ } from "./scheduler";
@@ -13,3 +13,9 @@ export {
13
13
  type DistributedLockConfig,
14
14
  type LockResult,
15
15
  } from './DistributedLock';
16
+
17
+ export {
18
+ withLock,
19
+ type WithLockOptions,
20
+ type LockOutcome,
21
+ } from './withLock';
@@ -0,0 +1,98 @@
1
+ /**
2
+ * withLock — run a function while holding a PostgreSQL advisory lock.
3
+ *
4
+ * Thin convenience wrapper over the shared {@link DistributedLock} singleton.
5
+ * Acquires the lock for `key`, runs `fn`, and always releases it — even if
6
+ * `fn` throws. Only one holder of a given `key` runs `fn` at a time, across
7
+ * every process pointed at the same database. When the lock is unavailable the
8
+ * call returns `{ acquired: false }` without running `fn` (unless `wait` is
9
+ * set, in which case it polls until the lock frees or the deadline passes).
10
+ *
11
+ * Two layers of exclusion:
12
+ * - Across processes: PostgreSQL `pg_advisory_lock`, owned by the singleton's
13
+ * pinned connection (one PG session per instance).
14
+ * - Within a process: an in-memory `Set`. PostgreSQL advisory locks are
15
+ * *reentrant per session*, so two concurrent callers sharing this instance's
16
+ * session would both win the pg lock — the Set makes same-process contention
17
+ * exclusive too.
18
+ *
19
+ * Notes:
20
+ * - Not reentrant. Calling `withLock(key, …)` for a key already held by this
21
+ * process returns `{ acquired: false }` (or waits, then gives up).
22
+ * - Shares the scheduler's singleton + PG session. Keys live under the same
23
+ * namespace prefix as scheduler task ids — pick keys unlikely to collide.
24
+ * - Honors the singleton's `enabled` config: if distributed locking was
25
+ * disabled (`getDistributedLock({ enabled: false })`), `tryAcquire` always
26
+ * reports success and no real lock is taken.
27
+ *
28
+ * @example
29
+ * const res = await withLock("rebuild-search-index", async () => {
30
+ * await rebuildIndex();
31
+ * return "done";
32
+ * });
33
+ * if (!res.acquired) {
34
+ * // another instance is already rebuilding — skip
35
+ * } else {
36
+ * console.log(res.result); // "done"
37
+ * }
38
+ */
39
+ import { getDistributedLock } from "./DistributedLock";
40
+
41
+ export interface WithLockOptions {
42
+ /** Max ms to wait for the lock before giving up. 0 (default) = try once. */
43
+ wait?: number;
44
+ /** Poll interval while waiting, in ms. Default 100. */
45
+ retryInterval?: number;
46
+ }
47
+
48
+ export type LockOutcome<T> =
49
+ | { acquired: false; result?: undefined }
50
+ | { acquired: true; result: T };
51
+
52
+ /** In-process holders, keyed by lock key (see "Within a process" above). */
53
+ const localHeld = new Set<string>();
54
+
55
+ const sleep = (ms: number): Promise<void> =>
56
+ new Promise((resolve) => setTimeout(resolve, ms));
57
+
58
+ export async function withLock<T>(
59
+ key: string,
60
+ fn: () => Promise<T> | T,
61
+ options: WithLockOptions = {}
62
+ ): Promise<LockOutcome<T>> {
63
+ const { wait = 0, retryInterval = 100 } = options;
64
+ const deadline = wait > 0 ? Date.now() + wait : 0;
65
+
66
+ // In-process gate. The has-check that exits the loop and the subsequent
67
+ // add() run without an await between them, so this is atomic on JS's single
68
+ // thread — concurrent same-key callers cannot both pass.
69
+ while (localHeld.has(key)) {
70
+ if (!deadline || Date.now() >= deadline) {
71
+ return { acquired: false };
72
+ }
73
+ await sleep(retryInterval);
74
+ }
75
+ localHeld.add(key);
76
+
77
+ try {
78
+ const lock = getDistributedLock();
79
+
80
+ let acquired = (await lock.tryAcquire(key)).acquired;
81
+ while (!acquired && deadline && Date.now() < deadline) {
82
+ await sleep(retryInterval);
83
+ acquired = (await lock.tryAcquire(key)).acquired;
84
+ }
85
+
86
+ if (!acquired) {
87
+ return { acquired: false };
88
+ }
89
+
90
+ try {
91
+ return { acquired: true, result: await fn() };
92
+ } finally {
93
+ await lock.release(key);
94
+ }
95
+ } finally {
96
+ localHeld.delete(key);
97
+ }
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
@@ -3,6 +3,7 @@ import type { QueryResult } from "./QueryNode";
3
3
  import { QueryContext } from "./QueryContext";
4
4
  import { shouldUseLateralJoins, shouldUseDirectPartition } from "../core/Config";
5
5
  import { FilterBuilderRegistry } from "./FilterBuilderRegistry";
6
+ import { jsonbInListCast } from "./FilterBuilder";
6
7
  import { ComponentRegistry } from "../core/components";
7
8
  import { getMetadataStorage } from "../core/metadata";
8
9
  import { assertIdentifier } from "./SqlIdentifier";
@@ -95,8 +96,9 @@ export class ComponentInclusionNode extends QueryNode {
95
96
  return `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
96
97
  } else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
97
98
  if (Array.isArray(filter.value) && filter.value.length > 0) {
98
- const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}`).join(', ');
99
- return `${jsonPath} ${filter.operator} (${placeholders})`;
99
+ const cast = jsonbInListCast(filter.value);
100
+ const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}${cast.param}`).join(', ');
101
+ return `${cast.lhs(jsonPath)} ${filter.operator} (${placeholders})`;
100
102
  } else if (Array.isArray(filter.value) && filter.value.length === 0) {
101
103
  return filter.operator === 'IN' ? 'FALSE' : 'TRUE';
102
104
  }
@@ -665,8 +667,9 @@ export class ComponentInclusionNode extends QueryNode {
665
667
  condition = `(${jsonPath})::boolean ${filter.operator} $${context.addParam(filter.value)}`;
666
668
  } else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
667
669
  if (Array.isArray(filter.value) && filter.value.length > 0) {
668
- const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}`).join(', ');
669
- condition = `${jsonPath} ${filter.operator} (${placeholders})`;
670
+ const cast = jsonbInListCast(filter.value);
671
+ const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}${cast.param}`).join(', ');
672
+ condition = `${cast.lhs(jsonPath)} ${filter.operator} (${placeholders})`;
670
673
  } else {
671
674
  return null; // Invalid or empty array - fall back to normal path
672
675
  }
@@ -860,14 +863,17 @@ export class ComponentInclusionNode extends QueryNode {
860
863
  // String LIKE/ILIKE comparison - no casting
861
864
  condition = `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
862
865
  } else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
863
- // IN/NOT IN comparison - handle arrays properly
866
+ // IN/NOT IN comparison - handle arrays properly. Numeric/
867
+ // boolean lists need a cast on both the JSONB text field
868
+ // and each parameter, else `text IN (1, 2)` errors.
864
869
  if (Array.isArray(filter.value) && filter.value.length > 0) {
870
+ const cast = jsonbInListCast(filter.value);
865
871
  let placeholders = '';
866
872
  for (let i = 0; i < filter.value.length; i++) {
867
873
  if (i) placeholders += ', ';
868
- placeholders += '$' + context.addParam(filter.value[i]);
874
+ placeholders += '$' + context.addParam(filter.value[i]) + cast.param;
869
875
  }
870
- condition = `${jsonPath} ${filter.operator} (${placeholders})`;
876
+ condition = `${cast.lhs(jsonPath)} ${filter.operator} (${placeholders})`;
871
877
  } else if (Array.isArray(filter.value) && filter.value.length === 0) {
872
878
  // Empty array: IN () is always false, NOT IN () is always true
873
879
  condition = filter.operator === 'IN' ? 'FALSE' : 'TRUE';
@@ -90,6 +90,25 @@ export function buildJSONBPath(field: string, alias: string): string {
90
90
  return `${alias}.data->'${field}'`;
91
91
  }
92
92
 
93
+ /**
94
+ * Determine the type cast for an `IN` / `NOT IN` value list against a JSONB
95
+ * text-extracted field (`data->>'x'` always yields text). Without a cast a
96
+ * numeric/boolean list produces `text IN (1, 2)` → PostgreSQL "operator does
97
+ * not exist: text = integer". When every element is a number (or boolean) we
98
+ * cast both the field and each parameter, mirroring the scalar `=` path. Mixed
99
+ * or string lists stay as plain text comparison (the correct default).
100
+ *
101
+ * @returns `lhs(path)` wraps the field expression; `param` is the per-parameter
102
+ * cast suffix (e.g. `::numeric`), `''` for text.
103
+ */
104
+ export function jsonbInListCast(values: any[]): { lhs: (path: string) => string; param: string } {
105
+ const allNumbers = values.length > 0 && values.every(v => typeof v === 'number');
106
+ if (allNumbers) return { lhs: (p) => `(${p})::numeric`, param: '::numeric' };
107
+ const allBooleans = values.length > 0 && values.every(v => typeof v === 'boolean');
108
+ if (allBooleans) return { lhs: (p) => `(${p})::boolean`, param: '::boolean' };
109
+ return { lhs: (p) => p, param: '' };
110
+ }
111
+
93
112
  /**
94
113
  * Compose multiple filter builders into a single builder that applies all conditions
95
114
  *
package/query/OrNode.ts CHANGED
@@ -5,6 +5,24 @@ import { OrQuery } from "./OrQuery";
5
5
  import { ComponentRegistry } from "../core/components";
6
6
  import { shouldUseDirectPartition } from "../core/Config";
7
7
  import { getMembershipTable } from "./membershipSource";
8
+ import { jsonbInListCast } from "./FilterBuilder";
9
+
10
+ /**
11
+ * Gate for the base-dependency single-pass OR rewrite (base scanned once,
12
+ * branches combined as OR-of-EXISTS instead of N× base + UNION + DISTINCT).
13
+ *
14
+ * Default ON — the single-pass shape is parity-proven against the legacy UNION
15
+ * path (identical exec/paginate/count results) and ~20× faster (no per-branch
16
+ * cartesian nested-loop; base anti-join computed once). Read at call time.
17
+ *
18
+ * Kill-switch: set BUNSANE_ORNODE_SINGLE_PASS=0 (or "false") to revert to the
19
+ * legacy UNION shape instantly, no redeploy — for the unlikely case a real
20
+ * Postgres planner regresses on a specific OR shape.
21
+ */
22
+ function shouldUseOrSinglePass(): boolean {
23
+ const v = process.env.BUNSANE_ORNODE_SINGLE_PASS;
24
+ return v !== '0' && v !== 'false';
25
+ }
8
26
 
9
27
  export class OrNode extends QueryNode {
10
28
  private orQuery: OrQuery;
@@ -111,15 +129,17 @@ export class OrNode extends QueryNode {
111
129
  break;
112
130
  case "IN":
113
131
  if (Array.isArray(value)) {
114
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
115
- branchSql += ` AND ${jsonPath} IN (${placeholders})`;
132
+ const cast = jsonbInListCast(value);
133
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
134
+ branchSql += ` AND ${cast.lhs(jsonPath)} IN (${placeholders})`;
116
135
  context.params.push(...value);
117
136
  }
118
137
  break;
119
138
  case "NOT IN":
120
139
  if (Array.isArray(value)) {
121
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
122
- branchSql += ` AND ${jsonPath} NOT IN (${placeholders})`;
140
+ const cast = jsonbInListCast(value);
141
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
142
+ branchSql += ` AND ${cast.lhs(jsonPath)} NOT IN (${placeholders})`;
123
143
  context.params.push(...value);
124
144
  }
125
145
  break;
@@ -150,7 +170,7 @@ export class OrNode extends QueryNode {
150
170
  if (context.excludedComponentIds.size > 0) {
151
171
  const excludedTypes = Array.from(context.excludedComponentIds);
152
172
  const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
153
- conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
173
+ conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.entity_id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
154
174
  context.params.push(...excludedTypes);
155
175
  }
156
176
 
@@ -232,15 +252,17 @@ export class OrNode extends QueryNode {
232
252
  break;
233
253
  case "IN":
234
254
  if (Array.isArray(value)) {
235
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
236
- conditions.push(`${jsonPath} IN (${placeholders})`);
255
+ const cast = jsonbInListCast(value);
256
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
257
+ conditions.push(`${cast.lhs(jsonPath)} IN (${placeholders})`);
237
258
  context.params.push(...value);
238
259
  }
239
260
  break;
240
261
  case "NOT IN":
241
262
  if (Array.isArray(value)) {
242
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
243
- conditions.push(`${jsonPath} NOT IN (${placeholders})`);
263
+ const cast = jsonbInListCast(value);
264
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
265
+ conditions.push(`${cast.lhs(jsonPath)} NOT IN (${placeholders})`);
244
266
  context.params.push(...value);
245
267
  }
246
268
  break;
@@ -329,15 +351,43 @@ export class OrNode extends QueryNode {
329
351
  let baseEntityQuery = "";
330
352
 
331
353
  if (hasComponentDependency) {
332
- // Get base entities from ComponentInclusionNode
354
+ // Get base entities from ComponentInclusionNode.
355
+ //
356
+ // CRITICAL: the base set must be UNBOUNDED. OrNode embeds this SQL
357
+ // as `FROM (base) WHERE EXISTS (<or filter>)` and applies LIMIT/
358
+ // OFFSET to the *final* OR-filtered result below. If the base node
359
+ // bakes the caller's LIMIT/OFFSET into its own SQL, the EXISTS OR
360
+ // filter only ever sees the first page of base entities (ordered by
361
+ // entity_id), so any match beyond that page silently vanishes —
362
+ // e.g. a search whose only hits live on page 2+ returns 0 rows
363
+ // while count() (which strips pagination) reports them. Null out
364
+ // pagination around the base build, then restore so the final
365
+ // pagination below is unaffected. cursorId is left intact: it
366
+ // constrains the candidate set (entity_id > cursor) which composes
367
+ // correctly with the final LIMIT.
333
368
  const componentNode = this.dependencies[0];
334
369
  if (componentNode) {
370
+ const savedLimit = context.limit;
371
+ const savedOffset = context.offsetValue;
372
+ context.limit = null;
373
+ context.offsetValue = 0;
335
374
  const baseResult = componentNode.execute(context);
375
+ context.limit = savedLimit;
376
+ context.offsetValue = savedOffset;
336
377
  baseEntityQuery = baseResult.sql;
337
378
  paramIndex = baseResult.context.paramIndex;
338
379
  }
339
380
  }
340
381
 
382
+ // Gated single-pass rewrite: scan the base set ONCE and combine the OR
383
+ // branches as a disjunction of EXISTS predicates, instead of embedding
384
+ // the base SQL inside every branch and UNION-ing (N× base scan +
385
+ // UNION dedup + redundant outer DISTINCT). Same param push order, same
386
+ // result set, same ORDER BY entity_id ASC + pagination semantics.
387
+ if (hasComponentDependency && baseEntityQuery && shouldUseOrSinglePass()) {
388
+ return this.executeBaseSinglePass(context, baseEntityQuery, paramIndex);
389
+ }
390
+
341
391
  // Build SQL for each branch
342
392
  for (const branch of this.orQuery.branches) {
343
393
  const componentId = ComponentRegistry.getComponentId(branch.component.name);
@@ -408,15 +458,17 @@ export class OrNode extends QueryNode {
408
458
  break;
409
459
  case "IN":
410
460
  if (Array.isArray(value)) {
411
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
412
- filterConditions.push(`${jsonPath} IN (${placeholders})`);
461
+ const cast = jsonbInListCast(value);
462
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
463
+ filterConditions.push(`${cast.lhs(jsonPath)} IN (${placeholders})`);
413
464
  context.params.push(...value);
414
465
  }
415
466
  break;
416
467
  case "NOT IN":
417
468
  if (Array.isArray(value)) {
418
- const placeholders = value.map(() => `$${paramIndex++}`).join(', ');
419
- filterConditions.push(`${jsonPath} NOT IN (${placeholders})`);
469
+ const cast = jsonbInListCast(value);
470
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
471
+ filterConditions.push(`${cast.lhs(jsonPath)} NOT IN (${placeholders})`);
420
472
  context.params.push(...value);
421
473
  }
422
474
  break;
@@ -447,7 +499,7 @@ export class OrNode extends QueryNode {
447
499
  for (const componentType of allComponentTypes) {
448
500
  const componentId = ComponentRegistry.getComponentId(componentType);
449
501
  if (componentId) {
450
- componentConditions.push(`EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_all WHERE ec_all.entity_id = or_results.id AND ec_all.type_id = $${paramIndex} AND ec_all.deleted_at IS NULL)`);
502
+ componentConditions.push(`EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_all WHERE ec_all.entity_id = or_results.entity_id AND ec_all.type_id = $${paramIndex} AND ec_all.deleted_at IS NULL)`);
451
503
  context.params.push(componentId);
452
504
  paramIndex++;
453
505
  }
@@ -469,7 +521,7 @@ export class OrNode extends QueryNode {
469
521
  if (context.excludedComponentIds.size > 0) {
470
522
  const excludedTypes = Array.from(context.excludedComponentIds);
471
523
  const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
472
- conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
524
+ conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.entity_id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
473
525
  context.params.push(...excludedTypes);
474
526
  }
475
527
 
@@ -498,7 +550,150 @@ export class OrNode extends QueryNode {
498
550
  params: context.params,
499
551
  context
500
552
  };
501
- } public getNodeType(): string {
553
+ }
554
+
555
+ /**
556
+ * Build a single OR branch as an `EXISTS (...)` predicate against the
557
+ * branch component's table, correlated to `idExpr` (e.g. `base.id`).
558
+ * Mirrors the filter switch of the legacy dependency branch exactly —
559
+ * same SQL fragments, same param push order — so the single-pass shape is
560
+ * result- and parameter-identical to the UNION shape it replaces.
561
+ */
562
+ private buildBranchExists(
563
+ branch: OrQuery['branches'][number],
564
+ idExpr: string,
565
+ context: QueryContext,
566
+ paramIndex: number
567
+ ): { sql: string; paramIndex: number } {
568
+ const componentId = ComponentRegistry.getComponentId(branch.component.name);
569
+ if (!componentId) {
570
+ throw new Error(`Component ${branch.component.name} is not registered`);
571
+ }
572
+
573
+ const componentTableName = this.getComponentTableName(componentId);
574
+ const componentIdParamIndex = paramIndex;
575
+
576
+ let sql = `EXISTS (
577
+ SELECT 1 FROM ${componentTableName} c
578
+ WHERE c.entity_id = ${idExpr}
579
+ AND c.type_id = $${componentIdParamIndex}
580
+ AND c.deleted_at IS NULL`;
581
+
582
+ context.params.push(componentId);
583
+ paramIndex++;
584
+
585
+ if (branch.filters && branch.filters.length > 0) {
586
+ for (const filter of branch.filters) {
587
+ const { field, operator, value } = filter;
588
+ const jsonPath = `c.data->>'${field}'`;
589
+
590
+ switch (operator) {
591
+ case "=":
592
+ case ">":
593
+ case "<":
594
+ case ">=":
595
+ case "<=":
596
+ case "!=":
597
+ if (typeof value === "string") {
598
+ sql += ` AND ${jsonPath} ${operator} $${paramIndex}`;
599
+ } else {
600
+ sql += ` AND (${jsonPath})::numeric ${operator} $${paramIndex}`;
601
+ }
602
+ context.params.push(value);
603
+ paramIndex++;
604
+ break;
605
+ case "LIKE":
606
+ case "ILIKE":
607
+ sql += ` AND ${jsonPath} ${operator} $${paramIndex}`;
608
+ context.params.push(value);
609
+ paramIndex++;
610
+ break;
611
+ case "IN":
612
+ if (Array.isArray(value)) {
613
+ const cast = jsonbInListCast(value);
614
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
615
+ sql += ` AND ${cast.lhs(jsonPath)} IN (${placeholders})`;
616
+ context.params.push(...value);
617
+ }
618
+ break;
619
+ case "NOT IN":
620
+ if (Array.isArray(value)) {
621
+ const cast = jsonbInListCast(value);
622
+ const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
623
+ sql += ` AND ${cast.lhs(jsonPath)} NOT IN (${placeholders})`;
624
+ context.params.push(...value);
625
+ }
626
+ break;
627
+ default:
628
+ throw new Error(`Unsupported operator: ${operator}`);
629
+ }
630
+ }
631
+ }
632
+
633
+ sql += ")";
634
+ return { sql, paramIndex };
635
+ }
636
+
637
+ /**
638
+ * Single-pass execution for OR-on-base queries. The base set (the embedded
639
+ * ComponentInclusionNode SQL) is referenced once; each OR branch becomes an
640
+ * EXISTS predicate OR-ed together. Exclusions, ordering and pagination
641
+ * match the UNION path exactly (the base node already applied exclusions —
642
+ * re-applying here is the same idempotent no-op the UNION path performed).
643
+ */
644
+ private executeBaseSinglePass(
645
+ context: QueryContext,
646
+ baseEntityQuery: string,
647
+ paramIndexStart: number
648
+ ): QueryResult {
649
+ let paramIndex = paramIndexStart;
650
+
651
+ const existsClauses: string[] = [];
652
+ for (const branch of this.orQuery.branches) {
653
+ const built = this.buildBranchExists(branch, 'base.id', context, paramIndex);
654
+ existsClauses.push(built.sql);
655
+ paramIndex = built.paramIndex;
656
+ }
657
+
658
+ let sql = `SELECT base.id as id FROM (${baseEntityQuery}) AS base WHERE (${existsClauses.join(' OR ')})`;
659
+
660
+ // Entity exclusions (idempotent — base already excluded them).
661
+ if (context.excludedEntityIds.size > 0) {
662
+ const excludedIds = Array.from(context.excludedEntityIds);
663
+ const placeholders = excludedIds.map(() => `$${paramIndex++}`).join(', ');
664
+ sql += ` AND base.id NOT IN (${placeholders})`;
665
+ context.params.push(...excludedIds);
666
+ }
667
+
668
+ // Component exclusions (idempotent — base already excluded them).
669
+ if (context.excludedComponentIds.size > 0) {
670
+ const excludedTypes = Array.from(context.excludedComponentIds);
671
+ const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
672
+ sql += ` AND NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = base.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`;
673
+ context.params.push(...excludedTypes);
674
+ }
675
+
676
+ sql += " ORDER BY base.id";
677
+
678
+ if (context.limit !== null) {
679
+ sql += ` LIMIT $${paramIndex++}`;
680
+ context.params.push(context.limit);
681
+ }
682
+ if (context.offsetValue > 0) {
683
+ sql += ` OFFSET $${paramIndex++}`;
684
+ context.params.push(context.offsetValue);
685
+ }
686
+
687
+ context.paramIndex = paramIndex;
688
+
689
+ return {
690
+ sql,
691
+ params: context.params,
692
+ context
693
+ };
694
+ }
695
+
696
+ public getNodeType(): string {
502
697
  return "OrNode";
503
698
  }
504
699
  }