bunsane 0.5.1 → 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/ArcheType.ts +1 -1
- package/core/app/metricsCollector.ts +2 -1
- package/core/app/requestRouter.ts +33 -4
- package/core/app/studioRouter.ts +14 -0
- package/core/archetype/zodSchemaBuilder.ts +6 -1
- package/core/entity/saveEntity.ts +2 -2
- package/core/index.ts +19 -0
- package/core/metadata/metadata-storage.ts +59 -1
- package/core/scheduler/index.ts +6 -0
- package/core/scheduler/withLock.ts +98 -0
- package/database/DatabaseHelper.ts +50 -15
- package/endpoints/entity.ts +86 -1
- package/endpoints/index.ts +2 -1
- package/endpoints/types.ts +25 -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/studio/dist/assets/index-BFzJDkHx.js +254 -0
- package/studio/dist/assets/index-TmEdOhTL.css +1 -0
- package/studio/dist/index.html +2 -2
- package/studio/dist/assets/index-BMZ67Npg.js +0 -254
- package/studio/dist/assets/index-BpbuYz9g.css +0 -1
|
@@ -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
|
|