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 +22 -0
- package/core/app/requestRouter.ts +33 -4
- package/core/index.ts +19 -0
- package/core/scheduler/index.ts +6 -0
- package/core/scheduler/withLock.ts +98 -0
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +13 -7
- package/query/FilterBuilder.ts +19 -0
- package/query/OrNode.ts +212 -17
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
|
|
202
|
-
|
|
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
|
-
|
|
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 ${
|
|
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";
|
package/core/scheduler/index.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
99
|
-
|
|
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
|
|
669
|
-
|
|
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';
|
package/query/FilterBuilder.ts
CHANGED
|
@@ -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
|
|
115
|
-
|
|
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
|
|
122
|
-
|
|
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.
|
|
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
|
|
236
|
-
|
|
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
|
|
243
|
-
|
|
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
|
|
412
|
-
|
|
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
|
|
419
|
-
|
|
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.
|
|
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.
|
|
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
|
-
}
|
|
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
|
}
|