bunsane 0.5.2 → 0.5.3
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 +42 -16
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,7 @@ 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";
|
|
8
9
|
|
|
9
10
|
export class OrNode extends QueryNode {
|
|
10
11
|
private orQuery: OrQuery;
|
|
@@ -111,15 +112,17 @@ export class OrNode extends QueryNode {
|
|
|
111
112
|
break;
|
|
112
113
|
case "IN":
|
|
113
114
|
if (Array.isArray(value)) {
|
|
114
|
-
const
|
|
115
|
-
|
|
115
|
+
const cast = jsonbInListCast(value);
|
|
116
|
+
const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
|
|
117
|
+
branchSql += ` AND ${cast.lhs(jsonPath)} IN (${placeholders})`;
|
|
116
118
|
context.params.push(...value);
|
|
117
119
|
}
|
|
118
120
|
break;
|
|
119
121
|
case "NOT IN":
|
|
120
122
|
if (Array.isArray(value)) {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
+
const cast = jsonbInListCast(value);
|
|
124
|
+
const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
|
|
125
|
+
branchSql += ` AND ${cast.lhs(jsonPath)} NOT IN (${placeholders})`;
|
|
123
126
|
context.params.push(...value);
|
|
124
127
|
}
|
|
125
128
|
break;
|
|
@@ -150,7 +153,7 @@ export class OrNode extends QueryNode {
|
|
|
150
153
|
if (context.excludedComponentIds.size > 0) {
|
|
151
154
|
const excludedTypes = Array.from(context.excludedComponentIds);
|
|
152
155
|
const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
|
|
153
|
-
conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.
|
|
156
|
+
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
157
|
context.params.push(...excludedTypes);
|
|
155
158
|
}
|
|
156
159
|
|
|
@@ -232,15 +235,17 @@ export class OrNode extends QueryNode {
|
|
|
232
235
|
break;
|
|
233
236
|
case "IN":
|
|
234
237
|
if (Array.isArray(value)) {
|
|
235
|
-
const
|
|
236
|
-
|
|
238
|
+
const cast = jsonbInListCast(value);
|
|
239
|
+
const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
|
|
240
|
+
conditions.push(`${cast.lhs(jsonPath)} IN (${placeholders})`);
|
|
237
241
|
context.params.push(...value);
|
|
238
242
|
}
|
|
239
243
|
break;
|
|
240
244
|
case "NOT IN":
|
|
241
245
|
if (Array.isArray(value)) {
|
|
242
|
-
const
|
|
243
|
-
|
|
246
|
+
const cast = jsonbInListCast(value);
|
|
247
|
+
const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
|
|
248
|
+
conditions.push(`${cast.lhs(jsonPath)} NOT IN (${placeholders})`);
|
|
244
249
|
context.params.push(...value);
|
|
245
250
|
}
|
|
246
251
|
break;
|
|
@@ -329,10 +334,29 @@ export class OrNode extends QueryNode {
|
|
|
329
334
|
let baseEntityQuery = "";
|
|
330
335
|
|
|
331
336
|
if (hasComponentDependency) {
|
|
332
|
-
// Get base entities from ComponentInclusionNode
|
|
337
|
+
// Get base entities from ComponentInclusionNode.
|
|
338
|
+
//
|
|
339
|
+
// CRITICAL: the base set must be UNBOUNDED. OrNode embeds this SQL
|
|
340
|
+
// as `FROM (base) WHERE EXISTS (<or filter>)` and applies LIMIT/
|
|
341
|
+
// OFFSET to the *final* OR-filtered result below. If the base node
|
|
342
|
+
// bakes the caller's LIMIT/OFFSET into its own SQL, the EXISTS OR
|
|
343
|
+
// filter only ever sees the first page of base entities (ordered by
|
|
344
|
+
// entity_id), so any match beyond that page silently vanishes —
|
|
345
|
+
// e.g. a search whose only hits live on page 2+ returns 0 rows
|
|
346
|
+
// while count() (which strips pagination) reports them. Null out
|
|
347
|
+
// pagination around the base build, then restore so the final
|
|
348
|
+
// pagination below is unaffected. cursorId is left intact: it
|
|
349
|
+
// constrains the candidate set (entity_id > cursor) which composes
|
|
350
|
+
// correctly with the final LIMIT.
|
|
333
351
|
const componentNode = this.dependencies[0];
|
|
334
352
|
if (componentNode) {
|
|
353
|
+
const savedLimit = context.limit;
|
|
354
|
+
const savedOffset = context.offsetValue;
|
|
355
|
+
context.limit = null;
|
|
356
|
+
context.offsetValue = 0;
|
|
335
357
|
const baseResult = componentNode.execute(context);
|
|
358
|
+
context.limit = savedLimit;
|
|
359
|
+
context.offsetValue = savedOffset;
|
|
336
360
|
baseEntityQuery = baseResult.sql;
|
|
337
361
|
paramIndex = baseResult.context.paramIndex;
|
|
338
362
|
}
|
|
@@ -408,15 +432,17 @@ export class OrNode extends QueryNode {
|
|
|
408
432
|
break;
|
|
409
433
|
case "IN":
|
|
410
434
|
if (Array.isArray(value)) {
|
|
411
|
-
const
|
|
412
|
-
|
|
435
|
+
const cast = jsonbInListCast(value);
|
|
436
|
+
const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
|
|
437
|
+
filterConditions.push(`${cast.lhs(jsonPath)} IN (${placeholders})`);
|
|
413
438
|
context.params.push(...value);
|
|
414
439
|
}
|
|
415
440
|
break;
|
|
416
441
|
case "NOT IN":
|
|
417
442
|
if (Array.isArray(value)) {
|
|
418
|
-
const
|
|
419
|
-
|
|
443
|
+
const cast = jsonbInListCast(value);
|
|
444
|
+
const placeholders = value.map(() => `$${paramIndex++}${cast.param}`).join(', ');
|
|
445
|
+
filterConditions.push(`${cast.lhs(jsonPath)} NOT IN (${placeholders})`);
|
|
420
446
|
context.params.push(...value);
|
|
421
447
|
}
|
|
422
448
|
break;
|
|
@@ -447,7 +473,7 @@ export class OrNode extends QueryNode {
|
|
|
447
473
|
for (const componentType of allComponentTypes) {
|
|
448
474
|
const componentId = ComponentRegistry.getComponentId(componentType);
|
|
449
475
|
if (componentId) {
|
|
450
|
-
componentConditions.push(`EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_all WHERE ec_all.entity_id = or_results.
|
|
476
|
+
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
477
|
context.params.push(componentId);
|
|
452
478
|
paramIndex++;
|
|
453
479
|
}
|
|
@@ -469,7 +495,7 @@ export class OrNode extends QueryNode {
|
|
|
469
495
|
if (context.excludedComponentIds.size > 0) {
|
|
470
496
|
const excludedTypes = Array.from(context.excludedComponentIds);
|
|
471
497
|
const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
|
|
472
|
-
conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.
|
|
498
|
+
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
499
|
context.params.push(...excludedTypes);
|
|
474
500
|
}
|
|
475
501
|
|