bunsane 0.5.4 → 0.5.5

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.
@@ -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
- console.warn(
41
- `Could not generate schema for archetype ${archetypeName}:`,
42
- error
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
- console.warn(`Could not process relations for archetype ${archetypeMetadata.name}:`, error);
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
- console.warn(
212
- `Failed to weave all archetypes due to duplicate types.\n` +
213
- `Archetypes being processed: ${archetypeNames.join(', ')}\n` +
214
- `Error: ${error}`
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
@@ -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
- if (context.offsetValue > 0 || context.limit !== null) {
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
- if (context.cursorId === null && (context.offsetValue > 0 || context.limit !== null)) {
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
- sql += " ORDER BY id";
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
- sql += " ORDER BY entity_id";
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
- sql += " ORDER BY entity_id";
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
- sql += " ORDER BY base.id";
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
- // Build the DAG
842
- const dag = new QueryDAG();
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
- // Check if we have an OR query
845
- if (this.orQuery) {
846
- // For OR queries, we need to ensure entities have all required components first
847
- if (this.context.componentIds.size > 0) {
848
- // ComponentInclusionNode is the root, OrNode is the leaf
849
- const componentNode = new ComponentInclusionNode();
850
- dag.setRootNode(componentNode);
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
- // OrNode filters on top of the base requirements
853
- const orNode = new OrNode(this.orQuery);
854
- orNode.addDependency(componentNode);
855
- dag.addNode(orNode);
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
- // No base requirements, OrNode is both root and leaf
858
- const orNode = new OrNode(this.orQuery);
859
- dag.setRootNode(orNode);
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
- } else {
862
- // Use buildBasicQuery for regular AND logic (includes CTE optimization)
863
- const optimizedDag = QueryDAG.buildBasicQuery(this.context);
864
- // Copy nodes from optimized DAG to our DAG
865
- for (const node of optimizedDag.getNodes()) {
866
- dag.addNode(node);
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
- if (optimizedDag.getRootNode()) {
869
- dag.setRootNode(optimizedDag.getRootNode()!);
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
 
@@ -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
  }