bunsane 0.5.4 → 0.5.6
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/core/archetype/weaver.ts +23 -9
- package/endpoints/archetypes.ts +1 -1
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +210 -14
- package/query/OrNode.ts +16 -7
- package/query/Query.ts +398 -28
- package/query/QueryContext.ts +39 -1
- package/utils/archetypeIndicator.ts +54 -0
package/core/archetype/weaver.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { printSchema } from "graphql";
|
|
|
5
5
|
import { getMetadataStorage } from "../metadata";
|
|
6
6
|
import { componentSchemaCache } from "./schemaBuilder";
|
|
7
7
|
import { inputTypeRegistry, customTypeNameRegistry } from "./customTypes";
|
|
8
|
+
import { logger } from "../Logger";
|
|
8
9
|
|
|
9
10
|
export const archetypeSchemaCache = new Map<
|
|
10
11
|
string,
|
|
@@ -37,9 +38,9 @@ export function weaveAllArchetypes() {
|
|
|
37
38
|
const instance = new ArchetypeClass();
|
|
38
39
|
instance.getZodObjectSchema();
|
|
39
40
|
} catch (error) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
logger.warn(
|
|
42
|
+
{ scope: 'weaver', archetype: archetypeName, error },
|
|
43
|
+
`Could not generate schema for archetype ${archetypeName}`
|
|
43
44
|
);
|
|
44
45
|
}
|
|
45
46
|
}
|
|
@@ -125,7 +126,7 @@ export function weaveAllArchetypes() {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
} catch (error) {
|
|
128
|
-
|
|
129
|
+
logger.warn({ scope: 'weaver', archetype: archetypeMetadata.name, error }, `Could not process relations for archetype ${archetypeMetadata.name}`);
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
if (archetypeMetadata.functions) {
|
|
@@ -208,11 +209,24 @@ export function weaveAllArchetypes() {
|
|
|
208
209
|
|
|
209
210
|
return schemaString;
|
|
210
211
|
} catch (error) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
213
|
+
// graphql-js rejects a schema carrying two same-named types
|
|
214
|
+
// ("...must contain uniquely named types but contains multiple types
|
|
215
|
+
// named 'X'..."). The DeduplicationVisitor drops the duplicate and the
|
|
216
|
+
// schema still weaves and serves downstream, so this is recovered noise
|
|
217
|
+
// — log at debug. Any OTHER weave failure stays at warn so real
|
|
218
|
+
// breakage remains visible.
|
|
219
|
+
if (/multiple types named|uniquely named types/i.test(msg)) {
|
|
220
|
+
logger.debug(
|
|
221
|
+
{ scope: 'weaver', archetypes: archetypeNames },
|
|
222
|
+
`Duplicate GraphQL type during archetype weave — deduplicated, schema unaffected: ${msg}`
|
|
223
|
+
);
|
|
224
|
+
} else {
|
|
225
|
+
logger.warn(
|
|
226
|
+
{ scope: 'weaver', archetypes: archetypeNames, error },
|
|
227
|
+
'Failed to weave all archetypes'
|
|
228
|
+
);
|
|
229
|
+
}
|
|
216
230
|
return null;
|
|
217
231
|
}
|
|
218
232
|
}
|
package/endpoints/archetypes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getSerializedMetadataStorage } from "../core/metadata";
|
|
2
|
-
import { findIndicatorComponentName } from "../
|
|
2
|
+
import { findIndicatorComponentName } from "../utils/archetypeIndicator";
|
|
3
3
|
import db from "../database";
|
|
4
4
|
import type {
|
|
5
5
|
StudioArcheTypeQueryParams,
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ import { getMembershipSource, getMembershipTable } from "./membershipSource";
|
|
|
13
13
|
* Check if a component property is numeric based on metadata
|
|
14
14
|
* Used to apply proper casting in ORDER BY clauses for index usage
|
|
15
15
|
*/
|
|
16
|
-
function isNumericProperty(componentName: string, propertyName: string): boolean {
|
|
16
|
+
export function isNumericProperty(componentName: string, propertyName: string): boolean {
|
|
17
17
|
const storage = getMetadataStorage();
|
|
18
18
|
const typeId = storage.getComponentId(componentName);
|
|
19
19
|
|
|
@@ -135,6 +135,79 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
135
135
|
* sort when the predicate is highly selective — unlike the correlated
|
|
136
136
|
* subquery ORDER BY, which can never use an index for ordering.
|
|
137
137
|
*/
|
|
138
|
+
/**
|
|
139
|
+
* Build a composite keyset WHERE clause for a single-sort-key component query.
|
|
140
|
+
* Returns an empty string when no composite cursor is set.
|
|
141
|
+
*
|
|
142
|
+
* The ORDER BY shape is `sort_expr <dir> NULLS <x>, entity_id ASC` (Fix #1).
|
|
143
|
+
* For `cursorDirection='after'`:
|
|
144
|
+
* - ASC sort: (sort_expr, entity_id) > ($v, $id) → row comparison works
|
|
145
|
+
* - DESC sort: sort_expr < $v OR (sort_expr = $v AND entity_id > $id)
|
|
146
|
+
* For `cursorDirection='before'` (reverse — rarely used): opposite operators.
|
|
147
|
+
*
|
|
148
|
+
* Null sort values: placed last by default (NULLS LAST). A NULL cursor value
|
|
149
|
+
* means "after all NULL-sorted rows" — we exclude them entirely (`IS NOT NULL`).
|
|
150
|
+
*/
|
|
151
|
+
private buildCompositeCursorWhere(
|
|
152
|
+
context: QueryContext,
|
|
153
|
+
sortExpr: string,
|
|
154
|
+
isNumeric: boolean,
|
|
155
|
+
entityIdCol: string,
|
|
156
|
+
connective: 'WHERE' | 'AND'
|
|
157
|
+
): string {
|
|
158
|
+
if (!context.compositeCursor) return '';
|
|
159
|
+
if (context.sortOrders.length !== 1) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'sortedCursor() requires exactly one sort key on a component sortBy(). ' +
|
|
162
|
+
'Multi-key component sort cursors are not supported.'
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const sortOrder = context.sortOrders[0]!;
|
|
166
|
+
const isDesc = sortOrder.direction === 'DESC';
|
|
167
|
+
const isBefore = context.cursorDirection === 'before';
|
|
168
|
+
|
|
169
|
+
// 'before' direction for component sortBy cursors is not yet implemented:
|
|
170
|
+
// it requires reversing ORDER BY and post-reversing rows in JS.
|
|
171
|
+
// Throw rather than returning silently wrong pages.
|
|
172
|
+
if (isBefore) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"sortedCursor(token, 'before') is not supported for component sortBy(). " +
|
|
175
|
+
'Use OFFSET pagination or walk pages forward only.'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { v, id } = context.compositeCursor;
|
|
180
|
+
const cast = isNumeric ? '::numeric' : '::text';
|
|
181
|
+
const nullsLast = !sortOrder.nullsFirst; // default is NULLS LAST
|
|
182
|
+
|
|
183
|
+
if (v === null) {
|
|
184
|
+
// Cursor is inside the NULL region (sort value was null on the last seen row).
|
|
185
|
+
// Under NULLS LAST (ASC): NULLs appear at the end; advance within them by id.
|
|
186
|
+
// Under NULLS FIRST (DESC): NULLs appear at the start; after a null cursor the
|
|
187
|
+
// non-null region follows — but that case cannot arise for DESC NULLS FIRST
|
|
188
|
+
// because NULLs come first (they'd be returned before any non-null values).
|
|
189
|
+
// Simplest correct behaviour: walk within the null region by id tiebreak.
|
|
190
|
+
const idIdx = context.addParam(id);
|
|
191
|
+
return ` ${connective} (${sortExpr} IS NULL AND ${entityIdCol} > $${idIdx}::uuid)`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ASC+after: (sort_expr, id) > ($v, $id), plus NULL-sorted rows which come
|
|
195
|
+
// AFTER all non-null rows under NULLS LAST (forward direction, not yet visited).
|
|
196
|
+
if (!isDesc) {
|
|
197
|
+
const vIdx = context.addParam(v);
|
|
198
|
+
const idIdx = context.addParam(id);
|
|
199
|
+
const nullInclude = nullsLast ? ` OR ${sortExpr} IS NULL` : '';
|
|
200
|
+
return ` ${connective} ((${sortExpr}, ${entityIdCol}) > ($${vIdx}${cast}, $${idIdx}::uuid)${nullInclude})`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// DESC+after: values come in decreasing order; "after" (v,id) means smaller
|
|
204
|
+
// value, or same value and larger id (id is ASC within ties).
|
|
205
|
+
const vLtIdx = context.addParam(v);
|
|
206
|
+
const vEqIdx = context.addParam(v);
|
|
207
|
+
const idGtIdx = context.addParam(id);
|
|
208
|
+
return ` ${connective} (${sortExpr} < $${vLtIdx}${cast} OR (${sortExpr} = $${vEqIdx}${cast} AND ${entityIdCol} > $${idGtIdx}::uuid))`;
|
|
209
|
+
}
|
|
210
|
+
|
|
138
211
|
private applySortDrivenScan(context: QueryContext): string | null {
|
|
139
212
|
if (!ComponentInclusionNode.canUseSortDrivenScan(context)) return null;
|
|
140
213
|
|
|
@@ -220,26 +293,30 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
220
293
|
|
|
221
294
|
const extraConditions = conditions.length > 0 ? `\n AND ${conditions.join('\n AND ')}` : '';
|
|
222
295
|
|
|
296
|
+
// Composite keyset predicate (AND because WHERE already has type_id check).
|
|
297
|
+
const cursorWhere = this.buildCompositeCursorWhere(context, sortExpr, isNumeric, 's.entity_id', 'AND');
|
|
298
|
+
|
|
223
299
|
let sql: string;
|
|
224
300
|
if (driveDirect || !getMembershipSource().isLegacy) {
|
|
225
301
|
// Drive directly from the sort component (partition) table —
|
|
226
302
|
// membership and component data are the same row.
|
|
227
303
|
sql = `SELECT s.entity_id as id FROM ${sortTable} s
|
|
228
304
|
WHERE s.type_id = $${context.addParam(sortTypeId)}::text
|
|
229
|
-
AND s.deleted_at IS NULL${extraConditions}
|
|
230
|
-
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
|
|
305
|
+
AND s.deleted_at IS NULL${extraConditions}${cursorWhere}
|
|
306
|
+
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}, s.entity_id ASC`;
|
|
231
307
|
} else {
|
|
232
308
|
sql = `SELECT s.entity_id as id FROM entity_components ec
|
|
233
309
|
JOIN ${sortTable} s ON s.id = ec.component_id AND s.deleted_at IS NULL
|
|
234
310
|
WHERE ec.type_id = $${context.addParam(sortTypeId)}::text
|
|
235
|
-
AND ec.deleted_at IS NULL${extraConditions}
|
|
236
|
-
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
|
|
311
|
+
AND ec.deleted_at IS NULL${extraConditions}${cursorWhere}
|
|
312
|
+
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}, s.entity_id ASC`;
|
|
237
313
|
}
|
|
238
314
|
|
|
239
315
|
if (context.limit !== null) {
|
|
240
316
|
sql += ` LIMIT $${context.addParam(context.limit)}`;
|
|
241
317
|
}
|
|
242
|
-
|
|
318
|
+
// OFFSET is not used alongside composite cursor pagination.
|
|
319
|
+
if (!context.compositeCursor && (context.offsetValue > 0 || context.limit !== null)) {
|
|
243
320
|
sql += ` OFFSET $${context.addParam(context.offsetValue)}`;
|
|
244
321
|
}
|
|
245
322
|
|
|
@@ -520,6 +597,13 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
520
597
|
* This ensures that sorting and pagination work together correctly
|
|
521
598
|
*/
|
|
522
599
|
private applySortingWithComponentJoins(baseQuery: string, context: QueryContext): string {
|
|
600
|
+
if (context.compositeCursor && context.sortOrders.length !== 1) {
|
|
601
|
+
throw new Error(
|
|
602
|
+
'sortedCursor() requires exactly one sort key. ' +
|
|
603
|
+
'Multi-key component sort cursors are not supported.'
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
523
607
|
// Check if we can use the optimized direct partition sort
|
|
524
608
|
if (shouldUseDirectPartition() && context.sortOrders.length === 1) {
|
|
525
609
|
const optimized = this.applySortingOptimized(baseQuery, context);
|
|
@@ -584,12 +668,70 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
584
668
|
orderByClauses.push(`${subquery} ${sortOrder.direction} ${nullsClause}`);
|
|
585
669
|
}
|
|
586
670
|
|
|
671
|
+
if (context.compositeCursor && orderByClauses.length === 1 && context.sortOrders.length === 1) {
|
|
672
|
+
// Composite keyset on single sort key via CTE: materialize sort value,
|
|
673
|
+
// filter by keyset predicate, then apply LIMIT.
|
|
674
|
+
const sortOrder = context.sortOrders[0]!;
|
|
675
|
+
const isNumericSv = isNumericProperty(sortOrder.component, sortOrder.property);
|
|
676
|
+
const nullsClauseSv = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
677
|
+
const { v, id: cursorId } = context.compositeCursor;
|
|
678
|
+
const cast = isNumericSv ? '::numeric' : '::text';
|
|
679
|
+
const isDesc = sortOrder.direction === 'DESC';
|
|
680
|
+
const isBefore = context.cursorDirection === 'before';
|
|
681
|
+
|
|
682
|
+
// Strip the trailing " ASC|DESC NULLS FIRST|LAST" to get the raw scalar subquery.
|
|
683
|
+
const svExpr = orderByClauses[0]!.replace(/ (ASC|DESC) NULLS (FIRST|LAST)$/, '');
|
|
684
|
+
|
|
685
|
+
// 'before' for composite keyset is not implemented — throw instead of
|
|
686
|
+
// returning silently wrong pages.
|
|
687
|
+
if (isBefore) {
|
|
688
|
+
throw new Error(
|
|
689
|
+
"sortedCursor(token, 'before') is not supported for component sortBy(). " +
|
|
690
|
+
'Use OFFSET pagination or walk pages forward only.'
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const nullsLast = !sortOrder.nullsFirst;
|
|
695
|
+
let keysetWhere: string;
|
|
696
|
+
if (v === null) {
|
|
697
|
+
// Cursor is inside the NULL region: advance within it by id tiebreak.
|
|
698
|
+
const idIdx = context.addParam(cursorId);
|
|
699
|
+
keysetWhere = `(_sorted._sv IS NULL AND _sorted.id > $${idIdx}::uuid)`;
|
|
700
|
+
} else if (!isDesc) {
|
|
701
|
+
// ASC+after: row-comparison + include NULL-sorted rows (NULLS LAST →
|
|
702
|
+
// they appear at the very end, after all non-null rows).
|
|
703
|
+
const vIdx = context.addParam(v);
|
|
704
|
+
const idIdx = context.addParam(cursorId);
|
|
705
|
+
const nullInclude = nullsLast ? ' OR _sorted._sv IS NULL' : '';
|
|
706
|
+
keysetWhere = `((_sorted._sv, _sorted.id) > ($${vIdx}${cast}, $${idIdx}::uuid)${nullInclude})`;
|
|
707
|
+
} else {
|
|
708
|
+
// DESC+after: smaller value, or same value and larger id.
|
|
709
|
+
const vLtIdx = context.addParam(v);
|
|
710
|
+
const vEqIdx = context.addParam(v);
|
|
711
|
+
const idGtIdx = context.addParam(cursorId);
|
|
712
|
+
keysetWhere = `(_sorted._sv < $${vLtIdx}${cast} OR (_sorted._sv = $${vEqIdx}${cast} AND _sorted.id > $${idGtIdx}::uuid))`;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
let sql = `WITH _sorted AS (
|
|
716
|
+
SELECT base_entities.id, ${svExpr} AS _sv
|
|
717
|
+
FROM (${baseQuery}) AS base_entities
|
|
718
|
+
)
|
|
719
|
+
SELECT _sorted.id FROM _sorted
|
|
720
|
+
WHERE ${keysetWhere}
|
|
721
|
+
ORDER BY _sorted._sv ${sortOrder.direction} ${nullsClauseSv}, _sorted.id ASC`;
|
|
722
|
+
|
|
723
|
+
if (!context.paginationAppliedInCTE && context.limit !== null) {
|
|
724
|
+
sql += ` LIMIT $${context.addParam(context.limit)}`;
|
|
725
|
+
}
|
|
726
|
+
return sql;
|
|
727
|
+
}
|
|
728
|
+
|
|
587
729
|
// Wrap the base query as a subquery to get entity ids
|
|
588
730
|
let sql = `SELECT base_entities.id FROM (${baseQuery}) AS base_entities`;
|
|
589
731
|
|
|
590
732
|
// Add ORDER BY clause
|
|
591
733
|
if (orderByClauses.length > 0) {
|
|
592
|
-
sql += ` ORDER BY ${orderByClauses.join(', ')}`;
|
|
734
|
+
sql += ` ORDER BY ${orderByClauses.join(', ')}, base_entities.id ASC`;
|
|
593
735
|
} else {
|
|
594
736
|
// Fallback to entity id if no valid sort orders
|
|
595
737
|
sql += ` ORDER BY base_entities.id`;
|
|
@@ -602,7 +744,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
602
744
|
sql += ` LIMIT $${context.addParam(context.limit)}`;
|
|
603
745
|
}
|
|
604
746
|
// Only add OFFSET when not using cursor-based pagination
|
|
605
|
-
if (context.cursorId === null && (context.offsetValue > 0 || context.limit !== null)) {
|
|
747
|
+
if (!context.compositeCursor && context.cursorId === null && (context.offsetValue > 0 || context.limit !== null)) {
|
|
606
748
|
sql += ` OFFSET $${context.addParam(context.offsetValue)}`;
|
|
607
749
|
}
|
|
608
750
|
}
|
|
@@ -692,6 +834,9 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
692
834
|
? `(c.data->>'${safeProperty}')::numeric`
|
|
693
835
|
: `c.data->>'${safeProperty}'`;
|
|
694
836
|
|
|
837
|
+
// Composite keyset predicate (AND because WHERE already has other conditions).
|
|
838
|
+
const cursorWhere = this.buildCompositeCursorWhere(context, sortExpr, isNumeric, 'c.entity_id', 'AND');
|
|
839
|
+
|
|
695
840
|
let sql: string;
|
|
696
841
|
if (useDirectPartition || !getMembershipSource().isLegacy) {
|
|
697
842
|
// Direct access on the component (partition) table - most efficient.
|
|
@@ -699,8 +844,8 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
699
844
|
sql = `SELECT c.entity_id as id FROM ${componentTableName} c
|
|
700
845
|
WHERE c.type_id = $${context.addParam(sortTypeId)}::text
|
|
701
846
|
AND c.deleted_at IS NULL
|
|
702
|
-
AND ${filterConditions.join(' AND ')}
|
|
703
|
-
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
|
|
847
|
+
AND ${filterConditions.join(' AND ')}${cursorWhere}
|
|
848
|
+
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}, c.entity_id ASC`;
|
|
704
849
|
} else {
|
|
705
850
|
// Use entity_components junction
|
|
706
851
|
// No DISTINCT needed since each entity has one component of this type
|
|
@@ -708,8 +853,8 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
708
853
|
JOIN ${componentTableName} c ON c.id = ec.component_id AND c.deleted_at IS NULL
|
|
709
854
|
WHERE ec.type_id = $${context.addParam(sortTypeId)}::text
|
|
710
855
|
AND ec.deleted_at IS NULL
|
|
711
|
-
AND ${filterConditions.join(' AND ')}
|
|
712
|
-
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
|
|
856
|
+
AND ${filterConditions.join(' AND ')}${cursorWhere}
|
|
857
|
+
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}, c.entity_id ASC`;
|
|
713
858
|
}
|
|
714
859
|
|
|
715
860
|
// Add pagination
|
|
@@ -717,7 +862,8 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
717
862
|
if (context.limit !== null) {
|
|
718
863
|
sql += ` LIMIT $${context.addParam(context.limit)}`;
|
|
719
864
|
}
|
|
720
|
-
|
|
865
|
+
// OFFSET is not used alongside composite cursor pagination.
|
|
866
|
+
if (!context.compositeCursor && context.cursorId === null && (context.offsetValue > 0 || context.limit !== null)) {
|
|
721
867
|
sql += ` OFFSET $${context.addParam(context.offsetValue)}`;
|
|
722
868
|
}
|
|
723
869
|
}
|
|
@@ -759,8 +905,58 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
759
905
|
LIMIT 1
|
|
760
906
|
)`;
|
|
761
907
|
|
|
908
|
+
if (context.compositeCursor) {
|
|
909
|
+
// Composite keyset via CTE: materialize sort value, filter, then paginate.
|
|
910
|
+
const { v, id: cursorId } = context.compositeCursor;
|
|
911
|
+
const cast = isNumeric ? '::numeric' : '::text';
|
|
912
|
+
const isDesc = sortOrder.direction === 'DESC';
|
|
913
|
+
const isBefore = context.cursorDirection === 'before';
|
|
914
|
+
|
|
915
|
+
// 'before' for composite keyset is not implemented — throw instead of
|
|
916
|
+
// returning silently wrong pages.
|
|
917
|
+
if (isBefore) {
|
|
918
|
+
throw new Error(
|
|
919
|
+
"sortedCursor(token, 'before') is not supported for component sortBy(). " +
|
|
920
|
+
'Use OFFSET pagination or walk pages forward only.'
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const nullsLast = !sortOrder.nullsFirst;
|
|
925
|
+
let keysetWhere: string;
|
|
926
|
+
if (v === null) {
|
|
927
|
+
// Cursor is inside the NULL region: advance within it by id tiebreak.
|
|
928
|
+
const idIdx = context.addParam(cursorId);
|
|
929
|
+
keysetWhere = `(_sorted._sv IS NULL AND _sorted.id > $${idIdx}::uuid)`;
|
|
930
|
+
} else if (!isDesc) {
|
|
931
|
+
// ASC+after: row-comparison + include NULL-sorted rows (NULLS LAST →
|
|
932
|
+
// they appear at the very end, after all non-null rows).
|
|
933
|
+
const vIdx = context.addParam(v);
|
|
934
|
+
const idIdx = context.addParam(cursorId);
|
|
935
|
+
const nullInclude = nullsLast ? ' OR _sorted._sv IS NULL' : '';
|
|
936
|
+
keysetWhere = `((_sorted._sv, _sorted.id) > ($${vIdx}${cast}, $${idIdx}::uuid)${nullInclude})`;
|
|
937
|
+
} else {
|
|
938
|
+
// DESC+after: smaller value, or same value and larger id.
|
|
939
|
+
const vLtIdx = context.addParam(v);
|
|
940
|
+
const vEqIdx = context.addParam(v);
|
|
941
|
+
const idGtIdx = context.addParam(cursorId);
|
|
942
|
+
keysetWhere = `(_sorted._sv < $${vLtIdx}${cast} OR (_sorted._sv = $${vEqIdx}${cast} AND _sorted.id > $${idGtIdx}::uuid))`;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
let sql = `WITH _sorted AS (
|
|
946
|
+
SELECT base.id, ${sortSubquery} AS _sv FROM (${baseQuery}) AS base
|
|
947
|
+
)
|
|
948
|
+
SELECT _sorted.id FROM _sorted
|
|
949
|
+
WHERE ${keysetWhere}
|
|
950
|
+
ORDER BY _sorted._sv ${sortOrder.direction} ${nullsClause}, _sorted.id ASC`;
|
|
951
|
+
|
|
952
|
+
if (!context.paginationAppliedInCTE && context.limit !== null) {
|
|
953
|
+
sql += ` LIMIT $${context.addParam(context.limit)}`;
|
|
954
|
+
}
|
|
955
|
+
return sql;
|
|
956
|
+
}
|
|
957
|
+
|
|
762
958
|
let sql = `SELECT base.id FROM (${baseQuery}) AS base
|
|
763
|
-
ORDER BY ${sortSubquery} ${sortOrder.direction} ${nullsClause}`;
|
|
959
|
+
ORDER BY ${sortSubquery} ${sortOrder.direction} ${nullsClause}, base.id ASC`;
|
|
764
960
|
|
|
765
961
|
// Add LIMIT and OFFSET only if not already applied in CTE
|
|
766
962
|
// When pagination is applied at CTE level, skip it here to avoid double pagination
|
package/query/OrNode.ts
CHANGED
|
@@ -178,8 +178,10 @@ export class OrNode extends QueryNode {
|
|
|
178
178
|
sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
// Add ordering
|
|
182
|
-
|
|
181
|
+
// Add ordering (skipped when an outer sort wrapper re-orders the set)
|
|
182
|
+
if (!context.suppressNodeOrdering) {
|
|
183
|
+
sql += " ORDER BY id";
|
|
184
|
+
}
|
|
183
185
|
|
|
184
186
|
// Add pagination
|
|
185
187
|
if (context.limit !== null) {
|
|
@@ -302,8 +304,10 @@ export class OrNode extends QueryNode {
|
|
|
302
304
|
sql += ` AND ${conditions.join(' AND ')}`;
|
|
303
305
|
}
|
|
304
306
|
|
|
305
|
-
// Add ordering
|
|
306
|
-
|
|
307
|
+
// Add ordering (skipped when an outer sort wrapper re-orders the set)
|
|
308
|
+
if (!context.suppressNodeOrdering) {
|
|
309
|
+
sql += " ORDER BY entity_id";
|
|
310
|
+
}
|
|
307
311
|
|
|
308
312
|
// Add pagination
|
|
309
313
|
if (context.limit !== null) {
|
|
@@ -529,8 +533,10 @@ export class OrNode extends QueryNode {
|
|
|
529
533
|
sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
530
534
|
}
|
|
531
535
|
|
|
532
|
-
// Add ordering
|
|
533
|
-
|
|
536
|
+
// Add ordering (skipped when an outer sort wrapper re-orders the set)
|
|
537
|
+
if (!context.suppressNodeOrdering) {
|
|
538
|
+
sql += " ORDER BY entity_id";
|
|
539
|
+
}
|
|
534
540
|
|
|
535
541
|
// Add pagination
|
|
536
542
|
if (context.limit !== null) {
|
|
@@ -673,7 +679,10 @@ export class OrNode extends QueryNode {
|
|
|
673
679
|
context.params.push(...excludedTypes);
|
|
674
680
|
}
|
|
675
681
|
|
|
676
|
-
|
|
682
|
+
// Add ordering (skipped when an outer sort wrapper re-orders the set)
|
|
683
|
+
if (!context.suppressNodeOrdering) {
|
|
684
|
+
sql += " ORDER BY base.id";
|
|
685
|
+
}
|
|
677
686
|
|
|
678
687
|
if (context.limit !== null) {
|
|
679
688
|
sql += ` LIMIT $${paramIndex++}`;
|
package/query/Query.ts
CHANGED
|
@@ -13,8 +13,9 @@ import { getMetadataStorage } from "../core/metadata";
|
|
|
13
13
|
import { shouldUseDirectPartition } from "../core/Config";
|
|
14
14
|
import type { SQL } from "bun";
|
|
15
15
|
import type { ComponentConstructor, TypedEntity, ComponentRecord } from "../types/query.types";
|
|
16
|
-
import { assertComponentTableName, assertFieldPath } from "./SqlIdentifier";
|
|
16
|
+
import { assertComponentTableName, assertFieldPath, assertIdentifier } from "./SqlIdentifier";
|
|
17
17
|
import { getMembershipSource } from "./membershipSource";
|
|
18
|
+
import { isNumericProperty } from "./ComponentInclusionNode";
|
|
18
19
|
|
|
19
20
|
// Parsed once at module load instead of on every exec() (process.env read +
|
|
20
21
|
// parseInt was on the query hot path). 0 disables the default limit.
|
|
@@ -307,6 +308,64 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
307
308
|
return this;
|
|
308
309
|
}
|
|
309
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Use composite keyset pagination for a SORTED query.
|
|
313
|
+
*
|
|
314
|
+
* Pass the opaque token returned by `Query.encodeSortedCursor(sortValue, entityId)`
|
|
315
|
+
* where `sortValue` is the sort column's raw value from the last row of the
|
|
316
|
+
* previous page, and `entityId` is that row's entity id. The query must have
|
|
317
|
+
* exactly one active sort key (sortByCreatedAt / sortByUpdatedAt / sortBy).
|
|
318
|
+
* Multi-key sort cursors are not supported — the method will throw at exec time.
|
|
319
|
+
*
|
|
320
|
+
* @example
|
|
321
|
+
* // Page 1
|
|
322
|
+
* const page1 = await new Query().with(MyComp).sortBy(MyComp, 'score', 'ASC').take(10).exec();
|
|
323
|
+
* const last = page1[page1.length - 1]!;
|
|
324
|
+
* // Build cursor from the last row's sort value.
|
|
325
|
+
* const token = Query.encodeSortedCursor(last.componentData['MyComp'].score, last.id);
|
|
326
|
+
*
|
|
327
|
+
* // Page 2
|
|
328
|
+
* const page2 = await new Query().with(MyComp).sortBy(MyComp, 'score', 'ASC').take(10).sortedCursor(token).exec();
|
|
329
|
+
*/
|
|
330
|
+
public sortedCursor(token: string, direction: 'after' | 'before' = 'after'): this {
|
|
331
|
+
this.context.compositeCursor = Query.decodeSortedCursor(token);
|
|
332
|
+
this.context.cursorDirection = direction;
|
|
333
|
+
// A composite cursor supersedes plain cursorId and OFFSET.
|
|
334
|
+
this.context.cursorId = null;
|
|
335
|
+
this.context.offsetValue = 0;
|
|
336
|
+
return this;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Encode a composite sort cursor from the last row's sort value and entity id.
|
|
341
|
+
* The sort value is stored as a string; pass the raw JS value (string, number,
|
|
342
|
+
* Date, or null). Dates are converted to ISO strings for timestamptz comparison.
|
|
343
|
+
*/
|
|
344
|
+
public static encodeSortedCursor(sortValue: string | number | Date | null, entityId: string): string {
|
|
345
|
+
let v: string | null;
|
|
346
|
+
if (sortValue === null || sortValue === undefined) {
|
|
347
|
+
v = null;
|
|
348
|
+
} else if (sortValue instanceof Date) {
|
|
349
|
+
v = sortValue.toISOString();
|
|
350
|
+
} else {
|
|
351
|
+
v = String(sortValue);
|
|
352
|
+
}
|
|
353
|
+
return Buffer.from(JSON.stringify({ v, id: entityId })).toString('base64');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Decode a composite sort cursor token. Returns `{v, id}`. */
|
|
357
|
+
public static decodeSortedCursor(token: string): { v: string | null; id: string } {
|
|
358
|
+
try {
|
|
359
|
+
const parsed = JSON.parse(Buffer.from(token, 'base64').toString('utf8'));
|
|
360
|
+
if (typeof parsed !== 'object' || parsed === null || typeof parsed.id !== 'string') {
|
|
361
|
+
throw new Error('malformed cursor');
|
|
362
|
+
}
|
|
363
|
+
return { v: parsed.v ?? null, id: parsed.id };
|
|
364
|
+
} catch {
|
|
365
|
+
throw new Error(`Invalid sorted cursor token: "${token}"`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
310
369
|
public sortBy<T extends BaseComponent>(
|
|
311
370
|
componentCtor: new (...args: any[]) => T,
|
|
312
371
|
property: keyof ComponentDataType<T>,
|
|
@@ -335,6 +394,36 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
|
335
394
|
return this;
|
|
336
395
|
}
|
|
337
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Sort by a native `entities`-table timestamp column (created_at /
|
|
399
|
+
* updated_at). Needs no component and no `.with()` — the column always
|
|
400
|
+
* exists on every entity and is a real indexed `timestamptz`, so this is
|
|
401
|
+
* cheaper than duplicating the timestamp into a JSONB component and
|
|
402
|
+
* sorting `data->>'...'`.
|
|
403
|
+
*
|
|
404
|
+
* Applied as an outer ORDER BY over the resolved id-set in doExec, so it
|
|
405
|
+
* composes with any `.with()` / filter combination. Cursor pagination is
|
|
406
|
+
* ignored when an entity sort is active (use .take()/.offset()).
|
|
407
|
+
*/
|
|
408
|
+
public sortByEntityField(
|
|
409
|
+
field: "created_at" | "updated_at",
|
|
410
|
+
direction: SortDirection = "ASC",
|
|
411
|
+
nullsFirst: boolean = false
|
|
412
|
+
): this {
|
|
413
|
+
this.context.entitySortOrders.push({ field, direction, nullsFirst });
|
|
414
|
+
return this;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Sort by entity creation time (`entities.created_at`). */
|
|
418
|
+
public sortByCreatedAt(direction: SortDirection = "ASC", nullsFirst: boolean = false): this {
|
|
419
|
+
return this.sortByEntityField("created_at", direction, nullsFirst);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Sort by entity last-update time (`entities.updated_at`). */
|
|
423
|
+
public sortByUpdatedAt(direction: SortDirection = "ASC", nullsFirst: boolean = false): this {
|
|
424
|
+
return this.sortByEntityField("updated_at", direction, nullsFirst);
|
|
425
|
+
}
|
|
426
|
+
|
|
338
427
|
public debugMode(enabled: boolean = true): this {
|
|
339
428
|
this.debug = enabled;
|
|
340
429
|
return this;
|
|
@@ -838,41 +927,322 @@ AND c.deleted_at IS NULL`;
|
|
|
838
927
|
// Reset context for fresh execution
|
|
839
928
|
this.context.reset();
|
|
840
929
|
|
|
841
|
-
//
|
|
842
|
-
|
|
930
|
+
// Entity-column sort (sortByCreatedAt/sortByUpdatedAt) and component
|
|
931
|
+
// sortBy() cannot be combined: the outer wrapper re-orders solely by
|
|
932
|
+
// the entity column, silently overriding the component sort.
|
|
933
|
+
if (this.context.entitySortOrders.length > 0 && this.context.sortOrders.length > 0) {
|
|
934
|
+
throw new Error(
|
|
935
|
+
'sortByCreatedAt()/sortByUpdatedAt() cannot be combined with sortBy() in the same query. ' +
|
|
936
|
+
'Use one or the other.'
|
|
937
|
+
);
|
|
938
|
+
}
|
|
843
939
|
|
|
844
|
-
//
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
940
|
+
// Native entity-column sort (created_at/updated_at) is applied as an
|
|
941
|
+
// outer ORDER BY over the resolved id-set. The inner nodes must emit
|
|
942
|
+
// the FULL set with no ordering/pagination of their own, else their
|
|
943
|
+
// LIMIT would truncate the wrong rows before we re-order. Stash and
|
|
944
|
+
// neutralize pagination for the inner build; restore + re-apply in the
|
|
945
|
+
// wrapper below.
|
|
946
|
+
const entitySorts = this.context.entitySortOrders;
|
|
947
|
+
const useEntitySort = entitySorts.length > 0;
|
|
948
|
+
// Component sortBy() on an OR query is also resolved by an outer
|
|
949
|
+
// wrapper: OrNode produces an unordered id-set, then we JOIN the sort
|
|
950
|
+
// component's data table and ORDER BY it (mirrors the entity-sort
|
|
951
|
+
// wrapper). The non-OR component sort path lives inside
|
|
952
|
+
// ComponentInclusionNode/CTE and is untouched.
|
|
953
|
+
const componentSorts = this.context.sortOrders;
|
|
954
|
+
const useOrComponentSort = !!this.orQuery && componentSorts.length > 0;
|
|
955
|
+
const useOuterSortWrapper = useEntitySort || useOrComponentSort;
|
|
956
|
+
|
|
957
|
+
let savedLimit: number | null = null;
|
|
958
|
+
let savedOffset = 0;
|
|
959
|
+
let savedCursorId: string | null = null;
|
|
960
|
+
let savedCompositeCursor: { v: string | null; id: string } | null = null;
|
|
961
|
+
const savedSuppressNodeOrdering = this.context.suppressNodeOrdering;
|
|
962
|
+
// Captured by reference before any neutralization below, so the wrapper
|
|
963
|
+
// still sees the requested sort keys after we clear context.sortOrders.
|
|
964
|
+
const savedSortOrders = this.context.sortOrders;
|
|
965
|
+
if (useOuterSortWrapper) {
|
|
966
|
+
savedLimit = this.context.limit;
|
|
967
|
+
savedOffset = this.context.offsetValue;
|
|
968
|
+
savedCursorId = this.context.cursorId;
|
|
969
|
+
savedCompositeCursor = this.context.compositeCursor;
|
|
970
|
+
this.context.limit = null;
|
|
971
|
+
this.context.offsetValue = 0;
|
|
972
|
+
this.context.cursorId = null;
|
|
973
|
+
this.context.compositeCursor = null;
|
|
974
|
+
// Only OrNode honours this; the inner OR id-set need not self-sort
|
|
975
|
+
// because the wrapper re-orders the full set. Non-OR inner nodes
|
|
976
|
+
// ignore the flag.
|
|
977
|
+
this.context.suppressNodeOrdering = true;
|
|
978
|
+
// For OR + component sortBy, clear the component sort keys for the
|
|
979
|
+
// inner build so the OR base node (ComponentInclusionNode) emits a
|
|
980
|
+
// plain unordered id-set — the wrapper below owns all ordering and
|
|
981
|
+
// its keyset/param accounting. (Entity sorts leave sortOrders empty.)
|
|
982
|
+
if (useOrComponentSort) {
|
|
983
|
+
this.context.sortOrders = [];
|
|
984
|
+
}
|
|
985
|
+
}
|
|
851
986
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
987
|
+
// Inner DAG build + entity-sort wrapper run with pagination
|
|
988
|
+
// neutralized (above). Restore in `finally` so a throw mid-build —
|
|
989
|
+
// including the entity-sort guards below — can never leave a reused
|
|
990
|
+
// Query instance with limit/offset/cursor nulled.
|
|
991
|
+
let result: { sql: string; params: any[]; context: QueryContext };
|
|
992
|
+
try {
|
|
993
|
+
// Build the DAG
|
|
994
|
+
const dag = new QueryDAG();
|
|
995
|
+
|
|
996
|
+
// Check if we have an OR query
|
|
997
|
+
if (this.orQuery) {
|
|
998
|
+
// For OR queries, we need to ensure entities have all required components first
|
|
999
|
+
if (this.context.componentIds.size > 0) {
|
|
1000
|
+
// ComponentInclusionNode is the root, OrNode is the leaf
|
|
1001
|
+
const componentNode = new ComponentInclusionNode();
|
|
1002
|
+
dag.setRootNode(componentNode);
|
|
1003
|
+
|
|
1004
|
+
// OrNode filters on top of the base requirements
|
|
1005
|
+
const orNode = new OrNode(this.orQuery);
|
|
1006
|
+
orNode.addDependency(componentNode);
|
|
1007
|
+
dag.addNode(orNode);
|
|
1008
|
+
} else {
|
|
1009
|
+
// No base requirements, OrNode is both root and leaf
|
|
1010
|
+
const orNode = new OrNode(this.orQuery);
|
|
1011
|
+
dag.setRootNode(orNode);
|
|
1012
|
+
}
|
|
856
1013
|
} else {
|
|
857
|
-
//
|
|
858
|
-
const
|
|
859
|
-
|
|
1014
|
+
// Use buildBasicQuery for regular AND logic (includes CTE optimization)
|
|
1015
|
+
const optimizedDag = QueryDAG.buildBasicQuery(this.context);
|
|
1016
|
+
// Copy nodes from optimized DAG to our DAG
|
|
1017
|
+
for (const node of optimizedDag.getNodes()) {
|
|
1018
|
+
dag.addNode(node);
|
|
1019
|
+
}
|
|
1020
|
+
if (optimizedDag.getRootNode()) {
|
|
1021
|
+
dag.setRootNode(optimizedDag.getRootNode()!);
|
|
1022
|
+
}
|
|
860
1023
|
}
|
|
861
|
-
|
|
862
|
-
//
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1024
|
+
|
|
1025
|
+
// Execute the DAG
|
|
1026
|
+
result = dag.execute(this.context);
|
|
1027
|
+
|
|
1028
|
+
// Wrap the resolved id-set with an outer ORDER BY on the native
|
|
1029
|
+
// entities column(s). result.params === this.context.params (same
|
|
1030
|
+
// ref), so pushing pagination params keeps placeholders sequential.
|
|
1031
|
+
if (useEntitySort) {
|
|
1032
|
+
if (savedCompositeCursor && entitySorts.length > 1) {
|
|
1033
|
+
throw new Error(
|
|
1034
|
+
'sortedCursor() does not support multi-key entity sorts. ' +
|
|
1035
|
+
'Only a single sortByCreatedAt() or sortByUpdatedAt() is supported with composite keyset pagination.'
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const orderClauses = entitySorts.map(s => {
|
|
1040
|
+
// Hard-mapped allow-list — never interpolate raw input.
|
|
1041
|
+
const col = s.field === "updated_at" ? "updated_at" : "created_at";
|
|
1042
|
+
const nulls = s.nullsFirst ? "NULLS FIRST" : "NULLS LAST";
|
|
1043
|
+
const dir = s.direction === "DESC" ? "DESC" : "ASC";
|
|
1044
|
+
return `e.${col} ${dir} ${nulls}`;
|
|
1045
|
+
}).join(", ");
|
|
1046
|
+
|
|
1047
|
+
let whereClause = '';
|
|
1048
|
+
if (savedCompositeCursor) {
|
|
1049
|
+
const isBefore = this.context.cursorDirection === 'before';
|
|
1050
|
+
if (isBefore) {
|
|
1051
|
+
// 'before' for sorted entity-column cursors is not yet implemented:
|
|
1052
|
+
// it requires reversing ORDER BY and post-reversing rows in JS.
|
|
1053
|
+
// Throw a clear error rather than returning silently wrong pages.
|
|
1054
|
+
throw new Error(
|
|
1055
|
+
"sortedCursor(token, 'before') is not supported for sortByCreatedAt()/sortByUpdatedAt(). " +
|
|
1056
|
+
'Use OFFSET pagination or walk pages forward only.'
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Composite keyset for native entity-column sort.
|
|
1061
|
+
// We truncate both sides to milliseconds so the JS Date (ms precision)
|
|
1062
|
+
// matches the stored TIMESTAMPTZ value. Without truncation, a stored
|
|
1063
|
+
// microsecond timestamp (e.g. 00:00:01.000123) always compares GREATER
|
|
1064
|
+
// than the ms-truncated cursor (00:00:01.000), causing already-seen
|
|
1065
|
+
// rows to re-qualify on every subsequent page.
|
|
1066
|
+
//
|
|
1067
|
+
// ORDER BY: date_trunc('milliseconds', col) <dir> NULLS x, base.id ASC
|
|
1068
|
+
// ASC+after: (trunc_col, id) > ($v, $id)
|
|
1069
|
+
// DESC+after: trunc_col < $v OR (trunc_col = $v AND id > $id)
|
|
1070
|
+
const s = entitySorts[0]!;
|
|
1071
|
+
const rawCol = s.field === "updated_at" ? "e.updated_at" : "e.created_at";
|
|
1072
|
+
const col = `date_trunc('milliseconds', ${rawCol})`;
|
|
1073
|
+
const isDesc = s.direction === "DESC";
|
|
1074
|
+
const { v, id: cursorId } = savedCompositeCursor;
|
|
1075
|
+
|
|
1076
|
+
if (v === null) {
|
|
1077
|
+
// After the last non-null row under NULLS LAST: for ASC the NULL
|
|
1078
|
+
// region follows all non-null rows — they've all been seen, so
|
|
1079
|
+
// nothing remains (entities.created_at is NOT NULL in practice,
|
|
1080
|
+
// but handle generically).
|
|
1081
|
+
whereClause = ' WHERE FALSE';
|
|
1082
|
+
} else if (!isDesc) {
|
|
1083
|
+
// ASC+after: row-comparison on truncated timestamp + id tiebreak.
|
|
1084
|
+
// Include NULL-timestamped rows too (NULLS LAST → they appear at
|
|
1085
|
+
// the very end, AFTER all non-null rows, so they have not yet been
|
|
1086
|
+
// visited when we are past a non-null cursor value).
|
|
1087
|
+
const vIdx = result.params.push(v);
|
|
1088
|
+
const idGtIdx = result.params.push(cursorId);
|
|
1089
|
+
whereClause = ` WHERE ((${col}, base.id) > ($${vIdx}::timestamptz, $${idGtIdx}::uuid) OR ${rawCol} IS NULL)`;
|
|
1090
|
+
} else {
|
|
1091
|
+
// DESC+after: values come in decreasing order; "after" the cursor
|
|
1092
|
+
// means smaller truncated timestamp, or same + larger id.
|
|
1093
|
+
const vLtIdx = result.params.push(v);
|
|
1094
|
+
const vEqIdx = result.params.push(v);
|
|
1095
|
+
const idGtIdx = result.params.push(cursorId);
|
|
1096
|
+
whereClause = ` WHERE (${col} < $${vLtIdx}::timestamptz OR (${col} = $${vEqIdx}::timestamptz AND base.id > $${idGtIdx}::uuid))`;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Mirror the ORDER BY truncation in the sort clause so the cursor
|
|
1101
|
+
// comparison and the ORDER BY operate on the same precision.
|
|
1102
|
+
const truncatedOrderClauses = entitySorts.map(s => {
|
|
1103
|
+
const rawCol = s.field === "updated_at" ? "e.updated_at" : "e.created_at";
|
|
1104
|
+
const col = `date_trunc('milliseconds', ${rawCol})`;
|
|
1105
|
+
const nulls = s.nullsFirst ? "NULLS FIRST" : "NULLS LAST";
|
|
1106
|
+
const dir = s.direction === "DESC" ? "DESC" : "ASC";
|
|
1107
|
+
return `${col} ${dir} ${nulls}`;
|
|
1108
|
+
}).join(", ");
|
|
1109
|
+
const effectiveOrderClauses = savedCompositeCursor ? truncatedOrderClauses : orderClauses;
|
|
1110
|
+
|
|
1111
|
+
let wrapped = `SELECT base.id FROM (${result.sql}) AS base
|
|
1112
|
+
JOIN entities e ON e.id = base.id${whereClause}
|
|
1113
|
+
ORDER BY ${effectiveOrderClauses}, base.id ASC`;
|
|
1114
|
+
|
|
1115
|
+
if (savedLimit !== null) {
|
|
1116
|
+
result.params.push(savedLimit);
|
|
1117
|
+
wrapped += ` LIMIT $${result.params.length}`;
|
|
1118
|
+
}
|
|
1119
|
+
// Only add OFFSET when not using cursor-based pagination
|
|
1120
|
+
if (!savedCompositeCursor && (savedOffset > 0 || savedLimit !== null)) {
|
|
1121
|
+
result.params.push(savedOffset);
|
|
1122
|
+
wrapped += ` OFFSET $${result.params.length}`;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
result.sql = wrapped;
|
|
1126
|
+
} else if (useOrComponentSort) {
|
|
1127
|
+
// OR + component sortBy(): wrap the unordered OR id-set with a
|
|
1128
|
+
// JOIN to each sort component's data table and an outer ORDER
|
|
1129
|
+
// BY. The sort component is always a required `.with()`
|
|
1130
|
+
// component (sortBy validates this), and the OR base node
|
|
1131
|
+
// guarantees every result entity has all required components,
|
|
1132
|
+
// so the INNER JOIN never drops a row. Composite keyset
|
|
1133
|
+
// pagination is supported for a single sort key; OFFSET for
|
|
1134
|
+
// any number of keys.
|
|
1135
|
+
if (savedCompositeCursor && componentSorts.length > 1) {
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
'sortedCursor() does not support multi-key sorts. ' +
|
|
1138
|
+
'Only a single sortBy() key is supported with composite keyset pagination on OR queries.'
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const joins: string[] = [];
|
|
1143
|
+
const orderClauses: string[] = [];
|
|
1144
|
+
componentSorts.forEach((s, i) => {
|
|
1145
|
+
const sortTypeId = ComponentRegistry.getComponentId(s.component);
|
|
1146
|
+
if (!sortTypeId) {
|
|
1147
|
+
throw new Error(`Component ${s.component} is not registered.`);
|
|
1148
|
+
}
|
|
1149
|
+
const table = shouldUseDirectPartition()
|
|
1150
|
+
? (ComponentRegistry.getPartitionTableName(sortTypeId) || 'components')
|
|
1151
|
+
: 'components';
|
|
1152
|
+
const safeTable = assertComponentTableName(table, 'orSort.componentTable');
|
|
1153
|
+
const alias = `s${i}`;
|
|
1154
|
+
const typeParamIdx = result.params.push(sortTypeId);
|
|
1155
|
+
joins.push(
|
|
1156
|
+
`JOIN ${safeTable} ${alias} ON ${alias}.entity_id = base.id ` +
|
|
1157
|
+
`AND ${alias}.type_id = $${typeParamIdx} AND ${alias}.deleted_at IS NULL`
|
|
1158
|
+
);
|
|
1159
|
+
const safeProp = assertIdentifier(s.property, 'sortOrder.property');
|
|
1160
|
+
const numeric = isNumericProperty(s.component, s.property);
|
|
1161
|
+
const expr = numeric
|
|
1162
|
+
? `(${alias}.data->>'${safeProp}')::numeric`
|
|
1163
|
+
: `${alias}.data->>'${safeProp}'`;
|
|
1164
|
+
const dir = s.direction === "DESC" ? "DESC" : "ASC";
|
|
1165
|
+
const nulls = s.nullsFirst ? "NULLS FIRST" : "NULLS LAST";
|
|
1166
|
+
orderClauses.push(`${expr} ${dir} ${nulls}`);
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
let whereClause = '';
|
|
1170
|
+
if (savedCompositeCursor) {
|
|
1171
|
+
const isBefore = this.context.cursorDirection === 'before';
|
|
1172
|
+
if (isBefore) {
|
|
1173
|
+
throw new Error(
|
|
1174
|
+
"sortedCursor(token, 'before') is not supported for OR + sortBy(). " +
|
|
1175
|
+
'Use OFFSET pagination or walk pages forward only.'
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
// Single-key keyset (guarded above). Re-derive the sort
|
|
1179
|
+
// expression on alias s0; the keyset shape mirrors
|
|
1180
|
+
// ComponentInclusionNode.buildCompositeCursorWhere exactly.
|
|
1181
|
+
const s = componentSorts[0]!;
|
|
1182
|
+
// NULLS FIRST + keyset cannot advance past the leading
|
|
1183
|
+
// null-block onto non-null rows (the (expr,id) comparison
|
|
1184
|
+
// never re-admits the front nulls), silently dropping rows.
|
|
1185
|
+
// Throw a clear error instead of returning wrong pages.
|
|
1186
|
+
if (s.nullsFirst) {
|
|
1187
|
+
throw new Error(
|
|
1188
|
+
'sortedCursor() does not support NULLS FIRST sorts. ' +
|
|
1189
|
+
'Use the default (NULLS LAST) or OFFSET pagination.'
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
const safeProp = assertIdentifier(s.property, 'sortOrder.property');
|
|
1193
|
+
const numeric = isNumericProperty(s.component, s.property);
|
|
1194
|
+
const expr = numeric
|
|
1195
|
+
? `(s0.data->>'${safeProp}')::numeric`
|
|
1196
|
+
: `s0.data->>'${safeProp}'`;
|
|
1197
|
+
const cast = numeric ? '::numeric' : '::text';
|
|
1198
|
+
const isDesc = s.direction === "DESC";
|
|
1199
|
+
const nullsLast = !s.nullsFirst;
|
|
1200
|
+
const { v, id: cursorId } = savedCompositeCursor;
|
|
1201
|
+
|
|
1202
|
+
if (v === null) {
|
|
1203
|
+
const idIdx = result.params.push(cursorId);
|
|
1204
|
+
whereClause = ` WHERE (${expr} IS NULL AND base.id > $${idIdx}::uuid)`;
|
|
1205
|
+
} else if (!isDesc) {
|
|
1206
|
+
const vIdx = result.params.push(v);
|
|
1207
|
+
const idIdx = result.params.push(cursorId);
|
|
1208
|
+
const nullInclude = nullsLast ? ` OR ${expr} IS NULL` : '';
|
|
1209
|
+
whereClause = ` WHERE ((${expr}, base.id) > ($${vIdx}${cast}, $${idIdx}::uuid)${nullInclude})`;
|
|
1210
|
+
} else {
|
|
1211
|
+
const vLtIdx = result.params.push(v);
|
|
1212
|
+
const vEqIdx = result.params.push(v);
|
|
1213
|
+
const idGtIdx = result.params.push(cursorId);
|
|
1214
|
+
whereClause = ` WHERE (${expr} < $${vLtIdx}${cast} OR (${expr} = $${vEqIdx}${cast} AND base.id > $${idGtIdx}::uuid))`;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
let wrapped = `SELECT base.id FROM (${result.sql}) AS base
|
|
1219
|
+
${joins.join(' ')}${whereClause}
|
|
1220
|
+
ORDER BY ${orderClauses.join(', ')}, base.id ASC`;
|
|
1221
|
+
|
|
1222
|
+
if (savedLimit !== null) {
|
|
1223
|
+
result.params.push(savedLimit);
|
|
1224
|
+
wrapped += ` LIMIT $${result.params.length}`;
|
|
1225
|
+
}
|
|
1226
|
+
if (!savedCompositeCursor && (savedOffset > 0 || savedLimit !== null)) {
|
|
1227
|
+
result.params.push(savedOffset);
|
|
1228
|
+
wrapped += ` OFFSET $${result.params.length}`;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
result.sql = wrapped;
|
|
867
1232
|
}
|
|
868
|
-
|
|
869
|
-
|
|
1233
|
+
} finally {
|
|
1234
|
+
if (useOuterSortWrapper) {
|
|
1235
|
+
// Restore so the Query instance stays reusable, even if the
|
|
1236
|
+
// DAG build/execute or a sort guard threw above.
|
|
1237
|
+
this.context.limit = savedLimit;
|
|
1238
|
+
this.context.offsetValue = savedOffset;
|
|
1239
|
+
this.context.cursorId = savedCursorId;
|
|
1240
|
+
this.context.compositeCursor = savedCompositeCursor;
|
|
1241
|
+
this.context.suppressNodeOrdering = savedSuppressNodeOrdering;
|
|
1242
|
+
this.context.sortOrders = savedSortOrders;
|
|
870
1243
|
}
|
|
871
1244
|
}
|
|
872
1245
|
|
|
873
|
-
// Execute the DAG
|
|
874
|
-
const result = dag.execute(this.context);
|
|
875
|
-
|
|
876
1246
|
// Get the database connection (transaction or default)
|
|
877
1247
|
const dbConn = this.getDb();
|
|
878
1248
|
|
package/query/QueryContext.ts
CHANGED
|
@@ -15,6 +15,20 @@ export interface SortOrder {
|
|
|
15
15
|
nullsFirst?: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Sort by a native column on the `entities` table (created_at / updated_at).
|
|
20
|
+
* Unlike SortOrder, this needs no component and no `.with()` — the column
|
|
21
|
+
* always exists and is indexed-friendly. Applied as an outer ORDER BY in
|
|
22
|
+
* Query.doExec, invisible to the SQL planner nodes.
|
|
23
|
+
*/
|
|
24
|
+
export type EntitySortField = "created_at" | "updated_at";
|
|
25
|
+
|
|
26
|
+
export interface EntitySortOrder {
|
|
27
|
+
field: EntitySortField;
|
|
28
|
+
direction: "ASC" | "DESC";
|
|
29
|
+
nullsFirst?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
export class QueryContext {
|
|
19
33
|
public params: any[] = [];
|
|
20
34
|
public paramIndex: number = 1;
|
|
@@ -24,6 +38,10 @@ export class QueryContext {
|
|
|
24
38
|
public excludedComponentIds: Set<string> = new Set();
|
|
25
39
|
public componentFilters: Map<string, QueryFilter[]> = new Map();
|
|
26
40
|
public sortOrders: SortOrder[] = [];
|
|
41
|
+
// Native entities-table sorts (created_at/updated_at). Separate channel:
|
|
42
|
+
// the SQL nodes never read it — Query.doExec wraps the id-set with a
|
|
43
|
+
// JOIN entities ... ORDER BY. Keeps the component sort paths untouched.
|
|
44
|
+
public entitySortOrders: EntitySortOrder[] = [];
|
|
27
45
|
public excludedEntityIds: Set<string> = new Set();
|
|
28
46
|
public withId: string | null = null;
|
|
29
47
|
public limit: number | null = null;
|
|
@@ -32,6 +50,14 @@ export class QueryContext {
|
|
|
32
50
|
// Cursor-based pagination (more efficient than OFFSET for large datasets)
|
|
33
51
|
public cursorId: string | null = null;
|
|
34
52
|
public cursorDirection: 'after' | 'before' = 'after';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Composite keyset cursor for sorted queries.
|
|
56
|
+
* Encodes both the last row's sort value and its entity_id so the
|
|
57
|
+
* predicate can be `(sort_expr, entity_id) > ($v, $id)` (or `<` for DESC).
|
|
58
|
+
* Only set via Query.sortedCursor(); plain .cursor() never sets this.
|
|
59
|
+
*/
|
|
60
|
+
public compositeCursor: { v: string | null; id: string } | null = null;
|
|
35
61
|
public hasCTE: boolean = false;
|
|
36
62
|
public cteName: string = "";
|
|
37
63
|
public eagerComponents: Set<string> = new Set();
|
|
@@ -42,6 +68,12 @@ export class QueryContext {
|
|
|
42
68
|
// suppressed.
|
|
43
69
|
public hasOrQuery: boolean = false;
|
|
44
70
|
|
|
71
|
+
// Set by Query.doExec while building an OR query whose final ordering is
|
|
72
|
+
// applied by an outer sort wrapper (entity-column or component sortBy).
|
|
73
|
+
// OrNode honours it by skipping its own `ORDER BY entity_id` so the inner
|
|
74
|
+
// id-set is not sorted twice (the outer wrapper re-orders the full set).
|
|
75
|
+
public suppressNodeOrdering: boolean = false;
|
|
76
|
+
|
|
45
77
|
private trx: SQL | undefined;
|
|
46
78
|
constructor(trx?: SQL) {
|
|
47
79
|
this.trx = trx;
|
|
@@ -115,6 +147,9 @@ export class QueryContext {
|
|
|
115
147
|
.map(s => `${s.component}.${s.property}:${s.direction}`)
|
|
116
148
|
.sort()
|
|
117
149
|
.join(',');
|
|
150
|
+
const entitySorts = this.entitySortOrders
|
|
151
|
+
.map(s => `@${s.field}:${s.direction}:${s.nullsFirst ? 'nf' : 'nl'}`)
|
|
152
|
+
.join(',');
|
|
118
153
|
|
|
119
154
|
// Extract custom filter operators for cache key differentiation
|
|
120
155
|
const customOperators = this.extractCustomOperators();
|
|
@@ -128,7 +163,7 @@ export class QueryContext {
|
|
|
128
163
|
const excludedEntityCount = this.excludedEntityIds.size;
|
|
129
164
|
const excludedEntitiesKey = excludedEntityCount > 0 ? `|excludedEntities:${excludedEntityCount}` : '';
|
|
130
165
|
|
|
131
|
-
const key = `${components}|${excludedComponents}|${filters}|${sorts}|${this.hasCTE}|${this.cteName}|${customOps}|${paginationKey}${excludedEntitiesKey}`;
|
|
166
|
+
const key = `${components}|${excludedComponents}|${filters}|${sorts}|${entitySorts}|${this.hasCTE}|${this.cteName}|${customOps}|${paginationKey}${excludedEntitiesKey}`;
|
|
132
167
|
return key;
|
|
133
168
|
}
|
|
134
169
|
|
|
@@ -160,17 +195,20 @@ export class QueryContext {
|
|
|
160
195
|
clone.excludedComponentIds = new Set(this.excludedComponentIds);
|
|
161
196
|
clone.componentFilters = new Map(this.componentFilters);
|
|
162
197
|
clone.sortOrders = [...this.sortOrders];
|
|
198
|
+
clone.entitySortOrders = [...this.entitySortOrders];
|
|
163
199
|
clone.excludedEntityIds = new Set(this.excludedEntityIds);
|
|
164
200
|
clone.withId = this.withId;
|
|
165
201
|
clone.limit = this.limit;
|
|
166
202
|
clone.offsetValue = this.offsetValue;
|
|
167
203
|
clone.cursorId = this.cursorId;
|
|
168
204
|
clone.cursorDirection = this.cursorDirection;
|
|
205
|
+
clone.compositeCursor = this.compositeCursor ? { ...this.compositeCursor } : null;
|
|
169
206
|
clone.hasCTE = this.hasCTE;
|
|
170
207
|
clone.cteName = this.cteName;
|
|
171
208
|
clone.eagerComponents = new Set(this.eagerComponents);
|
|
172
209
|
clone.paginationAppliedInCTE = this.paginationAppliedInCTE;
|
|
173
210
|
clone.hasOrQuery = this.hasOrQuery;
|
|
211
|
+
clone.suppressNodeOrdering = this.suppressNodeOrdering;
|
|
174
212
|
return clone;
|
|
175
213
|
}
|
|
176
214
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the indicator component name from an archetype's field list.
|
|
3
|
+
* The indicator is used to identify entities of this archetype type.
|
|
4
|
+
*
|
|
5
|
+
* Priority order:
|
|
6
|
+
* 1. {ArcheTypeName}Tag (e.g., UserTag)
|
|
7
|
+
* 2. {ArcheTypeName}Id (e.g., UserId)
|
|
8
|
+
* 3. Any field starting with {ArcheTypeName}
|
|
9
|
+
* 4. Any field containing {ArcheTypeName}
|
|
10
|
+
* 5. Fallback to first component
|
|
11
|
+
*/
|
|
12
|
+
export function findIndicatorComponentName(
|
|
13
|
+
archeTypeName: string,
|
|
14
|
+
fields: Array<{ componentName: string; fieldName: string }>
|
|
15
|
+
): string | null {
|
|
16
|
+
if (fields.length === 0) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const archeTypeNameLower = archeTypeName.toLowerCase();
|
|
21
|
+
const componentNames = fields.map(field => field.componentName);
|
|
22
|
+
|
|
23
|
+
const tagComponentName = `${archeTypeName}Tag`;
|
|
24
|
+
const tagMatch = componentNames.find(
|
|
25
|
+
name => name.toLowerCase() === tagComponentName.toLowerCase()
|
|
26
|
+
);
|
|
27
|
+
if (tagMatch) {
|
|
28
|
+
return tagMatch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const idComponentName = `${archeTypeName}Id`;
|
|
32
|
+
const idMatch = componentNames.find(
|
|
33
|
+
name => name.toLowerCase() === idComponentName.toLowerCase()
|
|
34
|
+
);
|
|
35
|
+
if (idMatch) {
|
|
36
|
+
return idMatch;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const startsWithMatch = componentNames.find(
|
|
40
|
+
name => name.toLowerCase().startsWith(archeTypeNameLower)
|
|
41
|
+
);
|
|
42
|
+
if (startsWithMatch) {
|
|
43
|
+
return startsWithMatch;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const containsMatch = componentNames.find(
|
|
47
|
+
name => name.toLowerCase().includes(archeTypeNameLower)
|
|
48
|
+
);
|
|
49
|
+
if (containsMatch) {
|
|
50
|
+
return containsMatch;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return componentNames[0] ?? null;
|
|
54
|
+
}
|