Repository: lolopinto/ent (language: python)

Issue:
GraphQL pagination doesn't work with nullsPlacement
If you are fetching a connection and defined your own order by with nullsPlacement, when you paginate, the generated clause doesn't account for null values. For instance, if your custom `CustomClauseQuery` generates the following SQL

```sql
SELECT
  DISTINCT *
FROM
  horses
WHERE
  (
    sire_id = $ 1
    OR dam_id = $ 2
  )
ORDER BY
  birth_year DESC NULLS LAST
  id DESC
LIMIT
  1001
```

The pagination query ends up looking like this:

```sql
SELECT
  DISTINCT *
FROM
  horses
WHERE
  (
    sire_id = $ 1
    OR dam_id = $ 2
  )
  AND (
    birth_year < $ 3
    OR (
      birth_year = $ 4
      AND id < $ 5
    )
  )
ORDER BY
  birth_year DESC NULLS LAST
  id DESC
LIMIT
  1001
```

But since I wasn't filtering by birth year to begin with, all entries with a null birth year will now not be included in the pagination.


Discussion / hints:

Fix patch:
diff --git a/ts/src/core/clause.ts b/ts/src/core/clause.ts
index 80e59b14c..26c939e12 100644
--- a/ts/src/core/clause.ts
+++ b/ts/src/core/clause.ts
@@ -167,7 +167,10 @@ class existsQueryClause<T extends Data, K = keyof T> extends queryClause<T, K> {
 }
 
 class isNullClause<T extends Data, K = keyof T> implements Clause<T, K> {
-  constructor(protected col: K, protected overrideAlias?: string) {}
+  constructor(
+    protected col: K,
+    protected overrideAlias?: string,
+  ) {}
 
   clause(_idx: number, alias?: string): string {
     return `${renderCol(this.col, this.overrideAlias, alias)} IS NULL`;
@@ -483,7 +486,10 @@ export class notInClause<T extends Data, K = keyof T> extends inClause<T, K> {
 class compositeClause<T extends Data, K = keyof T> implements Clause<T, K> {
   compositeOp: string;
 
-  constructor(private clauses: Clause<T, K>[], private sep: string) {
+  constructor(
+    private clauses: Clause<T, K>[],
+    private sep: string,
+  ) {
     this.compositeOp = this.sep;
   }
 
@@ -841,7 +847,7 @@ export function NotEq<T extends Data, K = keyof T>(
   overrideAlias?: string,
 ): Clause<T, K> {
   return new simpleClause<T, K>(col, value, "!=", {
-    handleNull: new isNotNullClause(col),
+    handleNull: new isNotNullClause(col, overrideAlias),
     overrideAlias,
   });
 }
@@ -1371,6 +1377,96 @@ export function PaginationMultipleColsSubQuery<T extends Data, K = keyof T>(
   );
 }
 
+export type PaginationUnboundColsQueryOrdering<T extends Data, K = keyof T> = {
+  sortCol: K;
+  direction: "ASC" | "DESC";
+  sortValue: string | number | null;
+  nullsPlacement?: "first" | "last";
+  overrideAlias?: string;
+};
+
+/**
+ * When paginating over multiple ordered columns, we need to construct a nested
+ * set of clauses to correctly filter. The resulting query will be structured as
+ * a group of nested ANDs and ORs.
+ *
+ * The four cases for each ordering entry are:
+ *
+ * 1) When sorting in ASC order with null placement LAST (e.g., [1,2,3,null,null])
+ *    a) If the sort value is not null, then it needs to check for values greater than the
+ *       sort value OR null values.
+ *    b) If the sort value is null, then it needs to check for null values only.
+ * 2) When sorting in ASC order with null placement FIRST (e.g., [null,null,1,2,3])
+ *    a) If the sort value is not null, then it needs to check for values greater than the
+ *       sort value.
+ *    b) If the sort value is null, then there will be no check for this value since all
+ *       possible values are greater than or equal to null.
+ * 3) When sorting in DESC order with null placement LAST (e.g., [1,2,3,null,null])
+ *    a) If the sort value is not null, then it needs to check for values less than the
+ *       sort value OR null values.
+ *    b) If the sort value is null, then it needs to check for null values only.
+ * 4) When sorting in DESC order with null placement FIRST (e.g., [null,null,1,2,3])
+ *    a) If the sort value is not null, then it needs to check for values less than the
+ *       sort value.
+ *    b) If the sort value is null, then there will be no check for this value since all
+ *       possible values are less than or equal to null.
+ *
+ * Each ordering entry will do the above check OR'ed with the following ordering entry and
+ * an equals check. For instance, given a set of ordering [{col1, less1}, {col2, greater2}],
+ * the function will construct a query that looks like:
+ *
+ * col1 < $1 OR (col1 = $1 AND col2 > $2)
+ *
+ * In english: each ordering entry will check for values lesser/greater than the supplied value,
+ * OR equal to the supplied value AND matching the next ordering entry. For the last ordering entry,
+ * it will not check for values equal to the supplied value.
+ */
+export function PaginationUnboundColsQuery<T extends Data, K = keyof T>(
+  ordering: PaginationUnboundColsQueryOrdering<T, K>[],
+): Clause<T, K> | undefined {
+  if (ordering.length === 0) {
+    throw new Error("Must provide at least one ordering.");
+  }
+  let nesting: Clause<T, K> | undefined;
+  // We reverse the order of the ordering array so that we can build the query from the inner-most
+  // clause and work our way outwards
+  ordering
+    .reverse()
+    .forEach(
+      ({ sortCol, direction, sortValue, nullsPlacement, overrideAlias }) => {
+        const nullsOrder =
+          nullsPlacement ?? (direction === "DESC" ? "first" : "last");
+        const clauseFn = direction === "DESC" ? Less : Greater;
+        const baseClause = clauseFn(sortCol, sortValue, overrideAlias);
+        const compoundClause =
+          sortValue !== null
+            ? nullsOrder === "last" // There is a non-null value to compare against
+              ? Or(baseClause, Eq(sortCol, null, overrideAlias)) // If nulls are last, then we need to compare the value and accept nulls
+              : And(baseClause, NotEq(sortCol, null, overrideAlias)) // If nulls are first, then we need to compare the value and reject nulls
+            : nullsOrder === "last" // The value to compare against is null
+              ? Eq(sortCol, null, overrideAlias) // If nulls are last, then we only accept nulls
+              : undefined; // If nulls first then any all values are accepted
+
+        // This will connect the compound clause to the previous clause for when the matching DB value equals the sort value
+        // E.g. if compoundClause is `col1 < $1` and nesting is `col2 < $2`, then the result will be
+        // `col1 < $1 OR (col1 = $1 AND col2 < $2)`
+        if (compoundClause) {
+          if (sortValue !== null) {
+            nesting = nesting
+              ? Or(
+                  compoundClause,
+                  And(Eq(sortCol, sortValue, overrideAlias), nesting),
+                )
+              : compoundClause;
+          } else {
+            nesting = nesting ? And(compoundClause, nesting) : compoundClause;
+          }
+        }
+      },
+    );
+  return nesting;
+}
+
 export function PaginationMultipleColsQuery<T extends Data, K = keyof T>(
   sortCol: K,
   cursorCol: K,
diff --git a/ts/src/core/db.ts b/ts/src/core/db.ts
index c077cc0f8..0a392ac27 100644
--- a/ts/src/core/db.ts
+++ b/ts/src/core/db.ts
@@ -1,8 +1,8 @@
-import pg, { Pool, PoolClient, PoolConfig } from "pg";
 import * as fs from "fs";
 import { load } from "js-yaml";
-import { log } from "./logger";
 import { DateTime } from "luxon";
+import pg, { Pool, PoolClient, PoolConfig } from "pg";
+import { log } from "./logger";
 
 export interface Database extends PoolConfig {
   database?: string;
@@ -232,9 +232,8 @@ export default class DB {
   }
 }
 
-export const defaultTimestampParser = pg.types.getTypeParser(
-  pg.types.builtins.TIMESTAMP,
-);
+export const defaultTimestampParser: (value: string) => string =
+  pg.types.getTypeParser(pg.types.builtins.TIMESTAMP);
 
 // this is stored in the db without timezone but we want to make sure
 // it's parsed as UTC time as opposed to the local time
@@ -441,7 +440,7 @@ export class Postgres implements Connection {
     values?: any[],
   ): Promise<QueryResult<QueryResultRow>> {
     const r = await this.pool.query(query, values);
-    return r;
+    return r as QueryResult<QueryResultRow>;
   }
 
   async queryAll(
@@ -449,7 +448,7 @@ export class Postgres implements Connection {
     values?: any[],
   ): Promise<QueryResult<QueryResultRow>> {
     const r = await this.pool.query(query, values);
-    return r;
+    return r as QueryResult<QueryResultRow>;
   }
 
   async exec(query: string, values?: any[]): Promise<ExecResult> {
@@ -473,7 +472,7 @@ export class PostgresClient implements Client {
     values?: any[],
   ): Promise<QueryResult<QueryResultRow>> {
     const r = await this.client.query(query, values);
-    return r;
+    return r as QueryResult<QueryResultRow>;
   }
 
   async queryAll(
@@ -481,7 +480,7 @@ export class PostgresClient implements Client {
     values?: any[],
   ): Promise<QueryResult<QueryResultRow>> {
     const r = await this.client.query(query, values);
-    return r;
+    return r as QueryResult<QueryResultRow>;
   }
 
   async exec(query: string, values?: any[]): Promise<ExecResult> {
diff --git a/ts/src/core/ent.ts b/ts/src/core/ent.ts
index e9e734d08..82800ab54 100644
--- a/ts/src/core/ent.ts
+++ b/ts/src/core/ent.ts
@@ -1261,43 +1261,44 @@ export class AssocEdge {
   getCursor(): string {
     return getCursor({
       row: this,
-      keys: ["id2"],
+      cursorKeys: ["time", "id2"],
     });
   }
 }
 
 export interface cursorOptions {
   row: Data;
-  keys: string[];
-  cursorKeys?: string[]; // used by tests. if cursor is from one column but the key in the name is different e.g. time for assocs and created_at when taken from the object
+  /**
+   * The keys that will be stored in the cursor. This should be the DB columns.
+   */
+  cursorKeys: string[];
+  /**
+   * The keys that will be used to get the values from the row. This should be
+   * the row object keys. If not provided, the cursorKeys will be used. This is
+   * only used for tests internally.
+   */
+  rowKeys?: string[];
 }
 
 // TODO eventually update this for sortCol time unique keys
 export function getCursor(opts: cursorOptions) {
-  const { row, keys, cursorKeys } = opts;
+  const { row, cursorKeys, rowKeys } = opts;
   //  row: Data, col: string, conv?: (any) => any) {
   if (!row) {
     throw new Error(`no row passed to getCursor`);
   }
-  if (cursorKeys?.length && cursorKeys.length !== keys.length) {
-    throw new Error("length of cursorKeys should match keys");
+  if (rowKeys?.length && rowKeys.length !== cursorKeys.length) {
+    throw new Error("length of cursorKeys should match rowKeys");
   }
-  const convert = (d: any) => {
-    if (d instanceof Date) {
-      return d.getTime();
-    }
-    return d;
-  };
+  const convert = (d: any) => (d instanceof Date ? d.toISOString() : d);
 
-  const parts: string[] = [];
-  for (let i = 0; i < keys.length; i++) {
-    const key = keys[i];
-    const cursorKey = cursorKeys?.[i] || key;
-    parts.push(key);
-    parts.push(convert(row[cursorKey]));
+  const parts: [string, string | number | null][] = [];
+  for (let i = 0; i < cursorKeys.length; i++) {
+    const cursorKey = cursorKeys[i];
+    const rowKey = rowKeys?.[i] || cursorKey;
+    parts.push([cursorKey, convert(row[rowKey])]);
   }
-  const str = parts.join(":");
-  return Buffer.from(str, "ascii").toString("base64");
+  return btoa(JSON.stringify(parts));
 }
 
 export class AssocEdgeData {
diff --git a/ts/src/core/loaders/assoc_edge_loader.ts b/ts/src/core/loaders/assoc_edge_loader.ts
index 84f680b28..c68550caf 100644
--- a/ts/src/core/loaders/assoc_edge_loader.ts
+++ b/ts/src/core/loaders/assoc_edge_loader.ts
@@ -1,28 +1,28 @@
 import DataLoader from "dataloader";
+import memoizee from "memoizee";
 import {
   Context,
-  ID,
   EdgeQueryableDataOptions,
+  ID,
   Loader,
   LoaderFactory,
 } from "../base";
+import * as clause from "../clause";
 import {
   AssocEdge,
-  loadCustomEdges,
   AssocEdgeConstructor,
-  loadEdgeData,
-  getDefaultLimit,
-  performRawQuery,
-  loadEdgeForID2,
-  buildGroupQuery,
   AssocEdgeData,
+  buildGroupQuery,
+  getDefaultLimit,
   getEdgeClauseAndFields,
+  loadCustomEdges,
+  loadEdgeData,
+  loadEdgeForID2,
   loadTwoWayEdges,
+  performRawQuery,
 } from "../ent";
-import * as clause from "../clause";
 import { logEnabled } from "../logger";
 import { CacheMap, getCustomLoader } from "./loader";
-import memoizee from "memoizee";
 
 function createLoader<T extends AssocEdge>(
   options: EdgeQueryableDataOptions,
@@ -263,7 +263,7 @@ export class AssocEdgeLoaderFactory<T extends AssocEdge>
     }
 
     // we create a loader which can combine first X queries in the same fetch
-    const key = `${this.name}:limit:${options.limit}:orderby:${options.orderby}:disableTransformations:${options.disableTransformations}`;
+    const key = `${this.name}:limit:${options.limit}:orderby:${options.orderby?.map((orderBy) => JSON.stringify(orderBy))}:disableTransformations:${options.disableTransformations}`;
     return getCustomLoader(
       key,
       () => new AssocEdgeLoader(this.edgeType, ctr, options, context),
diff --git a/ts/src/core/query/assoc_query.ts b/ts/src/core/query/assoc_query.ts
index 9982a7182..55ea09177 100644
--- a/ts/src/core/query/assoc_query.ts
+++ b/ts/src/core/query/assoc_query.ts
@@ -1,16 +1,15 @@
 import {
-  ID,
+  EdgeQueryableDataOptions,
   Ent,
-  Viewer,
+  ID,
   LoadEntOptions,
-  EdgeQueryableDataOptions,
-  QueryableDataOptions,
+  Viewer,
 } from "../base";
+import * as clause from "../clause";
 import { AssocEdge, loadEdgeData, loadEntsList } from "../ent";
 import { AssocEdgeCountLoaderFactory } from "../loaders/assoc_count_loader";
 import { AssocEdgeLoaderFactory } from "../loaders/assoc_edge_loader";
-import { EdgeQuery, BaseEdgeQuery, IDInfo, EdgeQueryFilter } from "./query";
-import * as clause from "../clause";
+import { BaseEdgeQuery, EdgeQuery, EdgeQueryFilter, IDInfo } from "./query";
 
 // TODO no more plurals for privacy reasons?
 export type EdgeQuerySource<
@@ -51,7 +50,19 @@ export abstract class AssocEdgeQueryBase<
       | LoadEntOptions<TDest, TViewer>
       | loaderOptionsFunc<TViewer>,
   ) {
-    super(viewer, "time", "id2");
+    super(viewer, {
+      cursorCol: "id2",
+      orderby: [
+        {
+          column: "time",
+          direction: "DESC",
+        },
+        {
+          column: "id2",
+          direction: "DESC",
+        },
+      ],
+    });
   }
 
   private isEdgeQuery(
@@ -299,11 +310,10 @@ export interface EdgeQueryCtr<
   TDest extends Ent,
   TEdge extends AssocEdge,
 > {
-  new (viewer: Viewer, src: EdgeQuerySource<TSource>): EdgeQuery<
-    TSource,
-    TDest,
-    TEdge
-  >;
+  new (
+    viewer: Viewer,
+    src: EdgeQuerySource<TSource>,
+  ): EdgeQuery<TSource, TDest, TEdge>;
 }
 
 class BeforeFilter implements EdgeQueryFilter<AssocEdge> {
@@ -331,7 +341,10 @@ class AfterFilter implements EdgeQueryFilter<AssocEdge> {
 }
 
 class WithinFilter implements EdgeQueryFilter<AssocEdge> {
-  constructor(private start: Date, private end: Date) {}
+  constructor(
+    private start: Date,
+    private end: Date,
+  ) {}
 
   query(options: EdgeQueryableDataOptions): EdgeQueryableDataOptions {
     const cls = clause.And(
diff --git a/ts/src/core/query/custom_clause_query.ts b/ts/src/core/query/custom_clause_query.ts
index 182febaac..9e6818e39 100644
--- a/ts/src/core/query/custom_clause_query.ts
+++ b/ts/src/core/query/custom_clause_query.ts
@@ -17,7 +17,7 @@ import {
 } from "../ent";
 import { OrderBy } from "../query_impl";
 
-import { BaseEdgeQuery, EdgeQueryOptions, IDInfo } from "./query";
+import { BaseEdgeQuery, IDInfo } from "./query";
 
 export interface CustomClauseQueryOptions<
   TDest extends Ent<TViewer>,
@@ -120,11 +120,6 @@ export class CustomClauseQuery<
     return this.options.loadEntOptions.tableName;
   }
 
-  protected includeSortColInCursor(options: EdgeQueryOptions) {
-    // TODO maybe we should just always do this?
-    return options.joinBETA !== undefined && this.sortCol !== this.cursorCol;
-  }
-
   async queryRawCount(): Promise<number> {
     // sqlite needs as count otherwise it returns count(1)
     let fields: SelectBaseDataOptions["fields"] = ["count(1) as count"];
@@ -167,7 +162,7 @@ export class CustomClauseQuery<
     if (!options.orderby) {
       options.orderby = [
         {
-          column: this.getSortCol(),
+          column: this.getCursorCol(),
           direction: this.options.orderByDirection ?? "DESC",
           nullsPlacement: this.options.nullsPlacement,
         },
diff --git a/ts/src/core/query/custom_query.ts b/ts/src/core/query/custom_query.ts
index 981ddeffe..d18cb97de 100644
--- a/ts/src/core/query/custom_query.ts
+++ b/ts/src/core/query/custom_query.ts
@@ -1,12 +1,12 @@
 import {
+  ConfigurableLoaderFactory,
   Data,
+  EdgeQueryableDataOptions,
   Ent,
   ID,
-  EdgeQueryableDataOptions,
   LoadEntOptions,
-  Viewer,
   LoaderFactory,
-  ConfigurableLoaderFactory,
+  Viewer,
 } from "../base";
 import { Clause, getCombinedClause } from "../clause";
 import { applyPrivacyPolicyForRows, getDefaultLimit } from "../ent";
@@ -16,7 +16,7 @@ import {
   RawCountLoader,
 } from "../loaders";
 import { OrderBy } from "../query_impl";
-import { BaseEdgeQuery, IDInfo, EdgeQuery } from "./query";
+import { BaseEdgeQuery, EdgeQuery, IDInfo } from "./query";
 
 // TODO kill this. only used in graphql tests
 export interface CustomEdgeQueryOptionsDeprecated<
@@ -272,7 +272,7 @@ export abstract class CustomEdgeQueryBase<
     if (!options.orderby) {
       options.orderby = [
         {
-          column: this.getSortCol(),
+          column: this.getCursorCol(),
           direction: "DESC",
         },
       ];
diff --git a/ts/src/core/query/query.ts b/ts/src/core/query/query.ts
index 368f2bbb2..fd9b6d2cd 100644
--- a/ts/src/core/query/query.ts
+++ b/ts/src/core/query/query.ts
@@ -1,21 +1,20 @@
+import memoize from "memoizee";
+import { isPromise } from "util/types";
 import {
-  ID,
-  Ent,
-  Viewer,
-  EdgeQueryableDataOptions,
   Data,
-  PrivacyPolicy,
+  EdgeQueryableDataOptions,
   EdgeQueryableDataOptionsConfigureLoader,
+  Ent,
+  ID,
+  PrivacyPolicy,
   QueryableDataOptions,
   SelectBaseDataOptions,
+  Viewer,
 } from "../base";
-import { getDefaultLimit, getCursor } from "../ent";
 import * as clause from "../clause";
-import memoize from "memoizee";
+import { getCursor, getDefaultLimit } from "../ent";
 import { AlwaysAllowPrivacyPolicy, applyPrivacyPolicy } from "../privacy";
-import { validate } from "uuid";
 import { OrderBy, reverseOrderBy } from "../query_impl";
-import { isPromise } from "util/types";
 
 export interface EdgeQuery<
   TSource extends Ent,
@@ -98,63 +97,33 @@ interface validCursorOptions {
   keys: string[];
 }
 
-function convertToIntMaybe(val: string) {
-  // TODO handle both cases... (time vs not) better
-  // TODO change this to only do the parseInt part if time...
-  // pass flag indicating if time?
-
-  // handle non-integers for which the first part is an int
-  // @ts-ignore
-  if (isNaN(val)) {
-    return val;
-  }
-  const time = parseInt(val, 10);
-  if (isNaN(time)) {
-    return val;
-  }
-  return time;
-}
-
-function assertValidCursor(cursor: string, opts: validCursorOptions): any {
-  let decoded = Buffer.from(cursor, "base64").toString("ascii");
-  let parts = decoded.split(":");
+export type CursorKeyValues = [key: string, value: string | number | null][];
 
+function translateCursorToKeyValues(
+  cursor: string,
+  opts: validCursorOptions,
+): CursorKeyValues {
   const { keys } = opts;
+  const decoded = atob(cursor);
+  let cursorData: CursorKeyValues = [];
+  try {
+    cursorData = JSON.parse(decoded);
+  } catch (error) {
+    throw new Error(`Bad cursor format ${cursor} passed`);
+  }
   // invalid or unknown cursor. nothing to do here.
-  // we should have the same number of parts as keys * 2
-  if (parts.length !== keys.length * 2) {
+  // we should have the same number of parts as keys
+  if (cursorData.length !== keys.length) {
     throw new Error(`invalid cursor ${cursor} passed`);
   }
-  const values: any = [];
-  for (let i = 0; i < keys.length; i++) {
-    const key = keys[i];
-    const keyPart = parts[i * 2];
-    if (key !== keyPart) {
-      throw new Error(
-        `invalid cursor ${cursor} passed. expected ${key}. got ${keyPart} as key of field`,
-      );
-    }
-    const val = parts[i * 2 + 1];
-    // uuid, don't parse int since it tries to validate just first part
-    if (validate(val)) {
-      values.push(val);
-      continue;
-    }
-
-    values.push(convertToIntMaybe(val));
-  }
-  return values;
+  return cursorData;
 }
 
 interface FilterOptions<T extends Data> {
   limit: number;
   query: BaseEdgeQuery<Ent, Ent, T>;
-  sortCol: string;
   cursorCol: string;
   orderby: OrderBy;
-  // if sortCol is unique and time, we need to pass different values for comparisons and checks...
-  sortColIsDate?: boolean;
-  cursorColIsDate?: boolean;
 
   cursorKeys: string[];
 
@@ -174,22 +143,24 @@ interface LastFilterOptions<T extends Data> extends FilterOptions<T> {
 const orderbyRegex = new RegExp(/([0-9a-z_]+)[ ]?([0-9a-z_]+)?/i);
 
 class FirstFilter<T extends Data> implements EdgeQueryFilter<T> {
-  private offset: any | undefined;
-  private sortCol: string;
+  /**
+   * The offset is the value of the last key in the cursor, which is the primary key for the table
+   */
+  private offset: string | number | undefined;
   private edgeQuery: BaseEdgeQuery<Ent, Ent, T>;
   private pageMap: Map<ID, PaginationInfo> = new Map();
   private usedQuery = false;
-  private cursorValues: any[] = [];
+  private cursorKeyValues: CursorKeyValues = [];
 
   constructor(private options: FirstFilterOptions<T>) {
     assertPositive(options.limit);
 
-    this.sortCol = options.sortCol;
     if (options.after) {
-      this.cursorValues = assertValidCursor(options.after, {
+      this.cursorKeyValues = translateCursorToKeyValues(options.after, {
         keys: options.cursorKeys,
       });
-      this.offset = this.cursorValues[0];
+      this.offset =
+        this.cursorKeyValues[this.cursorKeyValues.length - 1][1] ?? undefined;
     }
     this.edgeQuery = options.query;
   }
@@ -256,92 +227,36 @@ class FirstFilter<T extends Data> implements EdgeQueryFilter<T> {
     return edges;
   }
 
-  private getOffsetForQuery() {
-    // cursorCol maps to offset which we get from the cursor in assertValidCursor
-    return this.options.cursorColIsDate
-      ? new Date(this.offset).toISOString()
-      : this.offset;
-  }
-
-  private getSortValueForQuery() {
-    // sortCol maps to the value we're comparing against
-    return this.options.sortColIsDate
-      ? new Date(this.cursorValues[1]).toISOString()
-      : this.cursorValues[1];
-  }
-
   async query(
     options: EdgeQueryableDataOptions,
   ): Promise<EdgeQueryableDataOptions> {
     this.usedQuery = true;
 
     // we fetch an extra one to see if we're at the end
-    const limit = this.options.limit + 1;
+    options.limit = this.options.limit + 1;
 
-    options.limit = limit;
+    const orderBy = this.options.orderby;
 
-    // we sort by most recent first
-    // so when paging, we fetch afterCursor X
-    const less = this.options.orderby[0].direction === "DESC";
-    const orderby = this.options.orderby;
-
-    if (this.options.cursorCol !== this.sortCol) {
-      // we also sort cursor col in same direction. (direction doesn't matter)
-      orderby.push({
-        column: this.options.cursorCol,
-        direction: orderby[0].direction,
-      });
-
-      if (this.offset) {
-        const res = this.edgeQuery.getTableName();
-        let tableName = isPromise(res) ? await res : res;
-
-        // using a join, we already know sortCol and cursorCol are different
-        // we have encoded both values in the cursor
-        // includeSortColInCursor() is true in this case
-        if (
-          this.cursorValues.length === 2 &&
-          this.options.cursorKeys.length === 2
-        ) {
-          options.clause = clause.AndOptional(
-            options.clause,
-            clause.PaginationMultipleColsQuery(
-              this.sortCol,
-              this.options.cursorCol,
-              less,
-              this.getSortValueForQuery(),
-              this.getOffsetForQuery(),
-              this.options.fieldOptions?.fieldsAlias ??
-                this.options.fieldOptions?.alias,
-            ),
-          );
-        } else {
-          // inner col time
-
-          options.clause = clause.AndOptional(
-            options.clause,
-            clause.PaginationMultipleColsSubQuery(
-              this.sortCol,
-              less ? "<" : ">",
-              tableName,
-              this.options.cursorCol,
-              this.getOffsetForQuery(),
-            ),
-          );
-        }
-      }
-    } else {
-      if (this.offset) {
-        const clauseFn = less ? clause.Less : clause.Greater;
-        const val = this.getOffsetForQuery();
-        options.clause = clause.AndOptional(
-          options.clause,
-          clauseFn(this.sortCol, val),
-        );
+    if (this.offset) {
+      const keyValuePairs: { [key: string]: string | number | null } = {};
+      for (const [key, value] of this.cursorKeyValues) {
+        keyValuePairs[key] = value;
       }
+      options.clause = clause.AndOptional(
+        options.clause,
+        clause.PaginationUnboundColsQuery(
+          orderBy.map((orderBy) => ({
+            sortCol: orderBy.column,
+            sortValue: keyValuePairs[orderBy.column],
+            direction: orderBy.direction,
+            nullsPlacement: orderBy.nullsPlacement,
+            overrideAlias: orderBy.alias,
+          })),
+        ),
+      );
     }
-    options.orderby = orderby;
 
+    options.orderby = orderBy;
     return options;
   }
 
@@ -354,21 +269,23 @@ class FirstFilter<T extends Data> implements EdgeQueryFilter<T> {
 // TODO LastFilter same behavior as FirstFilter
 // TODO can we share so we don't keep needing to change in both
 class LastFilter<T extends Data> implements EdgeQueryFilter<T> {
-  private offset: any | undefined;
-  private sortCol: string;
+  /**
+   * The offset is the value of the last key in the cursor, which is the primary key for the table
+   */
+  private offset: string | number | undefined;
   private pageMap: Map<ID, PaginationInfo> = new Map();
   private edgeQuery: BaseEdgeQuery<Ent, Ent, T>;
-  private cursorValues: any[] = [];
+  private cursorKeyValues: CursorKeyValues = [];
 
   constructor(private options: LastFilterOptions<T>) {
     assertPositive(options.limit);
 
-    this.sortCol = options.sortCol;
     if (options.before) {
-      this.cursorValues = assertValidCursor(options.before, {
+      this.cursorKeyValues = translateCursorToKeyValues(options.before, {
         keys: options.cursorKeys,
       });
-      this.offset = this.cursorValues[0];
+      this.offset =
+        this.cursorKeyValues[this.cursorKeyValues.length - 1][1] ?? undefined;
     }
     this.edgeQuery = options.query;
   }
@@ -400,85 +317,34 @@ class LastFilter<T extends Data> implements EdgeQueryFilter<T> {
     return ret;
   }
 
-  // copied from FirstFilter
-  private getOffsetForQuery() {
-    // cursorCol maps to offset which we get from the cursor in assertValidCursor
-    return this.options.cursorColIsDate
-      ? new Date(this.offset).toISOString()
-      : this.offset;
-  }
-
-  private getSortValueForQuery() {
-    // sortCol maps to the value we're comparing against
-    return this.options.sortColIsDate
-      ? new Date(this.cursorValues[1]).toISOString()
-      : this.cursorValues[1];
-  }
-
   async query(
     options: EdgeQueryableDataOptions,
   ): Promise<EdgeQueryableDataOptions> {
-    const orderby = reverseOrderBy(this.options.orderby);
-    const greater = orderby[0].direction === "ASC";
-
-    options.limit = this.options.limit + 1; // fetch an extra so we know if previous pag
-
-    if (this.options.cursorCol !== this.sortCol) {
-      const res = this.edgeQuery.getTableName();
-      const tableName = isPromise(res) ? await res : res;
-
-      if (this.offset) {
-        // using a join, we already know sortCol and cursorCol are different
-        // we have encoded both values in the cursor
-        // includeSortColInCursor() is true in this case
-
-        if (
-          this.cursorValues.length === 2 &&
-          this.options.cursorKeys.length === 2
-        ) {
-          options.clause = clause.AndOptional(
-            options.clause,
-            clause.PaginationMultipleColsQuery(
-              this.sortCol,
-              this.options.cursorCol,
-              // flipped here since we're going in the opposite direction
-              !greater,
-              this.getSortValueForQuery(),
-              this.getOffsetForQuery(),
-              this.options.fieldOptions?.fieldsAlias ??
-                this.options.fieldOptions?.alias,
-            ),
-          );
-        } else {
-          // inner col time
-          options.clause = clause.AndOptional(
-            options.clause,
-            clause.PaginationMultipleColsSubQuery(
-              this.sortCol,
-              greater ? ">" : "<",
-              tableName,
-              this.options.cursorCol,
-              this.getOffsetForQuery(),
-            ),
-          );
-        }
-      }
-      // we also sort cursor col in same direction. (direction doesn't matter)
-      orderby.push({
-        column: this.options.cursorCol,
-        direction: orderby[0].direction,
-      });
-    } else {
-      if (this.offset) {
-        const clauseFn = greater ? clause.Greater : clause.Less;
-        const val = this.getOffsetForQuery();
-        options.clause = clause.AndOptional(
-          options.clause,
-          clauseFn(this.sortCol, val),
-        );
+    // we fetch an extra one to see if we're at the end
+    options.limit = this.options.limit + 1;
+
+    // we also sort cursor col in same direction. (direction doesn't matter)
+    const orderBy = reverseOrderBy(this.options.orderby);
+
+    if (this.offset) {
+      const keyValuePairs: { [key: string]: string | number | null } = {};
+      for (const [key, value] of this.cursorKeyValues) {
+        keyValuePairs[key] = value;
       }
+      options.clause = clause.AndOptional(
+        options.clause,
+        clause.PaginationUnboundColsQuery(
+          orderBy.map((orderBy) => ({
+            sortCol: orderBy.column,
+            sortValue: keyValuePairs[orderBy.column],
+            direction: orderBy.direction,
+            nullsPlacement: orderBy.nullsPlacement,
+            overrideAlias: orderBy.alias,
+          })),
+        ),
+      );
     }
-    options.orderby = orderby;
+    options.orderby = orderBy;
 
     return options;
   }
@@ -512,80 +378,44 @@ export abstract class BaseEdgeQuery<
   private idMap: Map<ID, TSource> = new Map();
   private idsToFetch: ID[] = [];
 
-  // this is the column we're sorting by e.g. created_at, name, id etc
-  protected sortCol: string;
   // if the column we're sorting by is not unique e.g. created_at, we add a secondary sort column which is used in the cursor
   // to break ties and ensure that we can paginate correctly
   protected cursorCol: string;
-  // both of these are used to determine if we need to convert the value to a date before comparing i.e. new Date(timestamp)->toISOString()
-  protected cursorColIsDate: boolean;
-  protected sortColIsDate: boolean;
   private edgeQueryOptions: EdgeQueryOptions;
   private limitAdded = false;
   private cursorKeys: string[] = [];
 
-  constructor(viewer: Viewer, sortCol: string, cursorCol: string);
-  constructor(viewer: Viewer, options: EdgeQueryOptions);
-
   constructor(
     public viewer: Viewer,
-    sortColOrOptions: string | EdgeQueryOptions,
-    cursorColMaybe?: string,
+    options: EdgeQueryOptions,
   ) {
-    let sortCol: string;
-    let cursorCol: string;
-    let sortColIsDate = false;
-    let cursorColIsDate = false;
-    if (typeof sortColOrOptions === "string") {
-      sortCol = sortColOrOptions;
-      cursorCol = cursorColMaybe!;
-      this.edgeQueryOptions = {
-        cursorCol,
-        orderby: [
-          {
-            column: sortCol,
-            direction: "DESC",
-          },
-        ],
-      };
-    } else {
-      if (typeof sortColOrOptions.orderby === "string") {
-        sortCol = sortColOrOptions.orderby;
-      } else {
-        // TODO this orderby isn't consistent and this logic needs to be changed anywhere that's using this and this.getSortCol()
-        sortCol = sortColOrOptions.orderby[0].column;
-        sortColIsDate = sortColOrOptions.orderby[0].dateColumn ?? false;
-      }
-      cursorCol = sortColOrOptions.cursorCol;
-      cursorColIsDate = sortColOrOptions.cursorColIsDate ?? false;
-      this.edgeQueryOptions = sortColOrOptions;
-    }
-    this.sortCol = sortCol;
-    this.sortColIsDate = sortColIsDate;
-    this.cursorColIsDate = cursorColIsDate;
-
-    let m = orderbyRegex.exec(sortCol);
-    if (!m) {
-      throw new Error(`invalid sort column ${sortCol}`);
-    }
-    this.sortCol = m[1];
-    if (m[2]) {
-      throw new Error(
-        `passing direction in sort column is not supproted. use orderby`,
-      );
+    // we also sort cursor col in same direction. (direction doesn't matter)
+    const orderBy = [...options.orderby];
+    if (
+      !options.orderby.some((orderBy) => orderBy.column === options.cursorCol)
+    ) {
+      orderBy.push({
+        column: options.cursorCol,
+        direction: orderBy?.[0].direction || "DESC",
+      });
     }
+    orderBy.forEach((o) => {
+      o.alias =
+        o.alias ??
+        (options.fieldOptions?.disableFieldsAlias
+          ? undefined
+          : (options.fieldOptions?.fieldsAlias ?? options.fieldOptions?.alias));
+    });
+    this.edgeQueryOptions = { ...options, orderby: orderBy };
+    this.cursorCol = options.cursorCol;
+    this.cursorKeys = orderBy.map((orderBy) => orderBy.column);
 
-    this.cursorCol = cursorCol;
     this.memoizedloadEdges = memoize(this.loadEdges.bind(this));
     this.genIDInfosToFetch = memoize(this.genIDInfosToFetchImpl.bind(this));
-    this.cursorKeys.push(this.cursorCol);
-    if (this.includeSortColInCursor(this.edgeQueryOptions)) {
-      this.cursorKeys.push(this.sortCol);
-    }
   }
 
-  protected getSortCol(): string {
-    return this.sortCol;
+  protected getCursorCol(): string {
+    return this.cursorCol;
   }
 
   getPrivacyPolicy() {
@@ -602,11 +432,8 @@ export abstract class BaseEdgeQuery<
       new FirstFilter({
         limit: n,
         after,
-        sortCol: this.sortCol,
         cursorCol: this.cursorCol,
         cursorKeys: this.cursorKeys,
-        cursorColIsDate: this.cursorColIsDate,
-        sortColIsDate: this.sortColIsDate,
         orderby: this.edgeQueryOptions.orderby,
         query: this,
         fieldOptions: this.edgeQueryOptions.fieldOptions,
@@ -634,11 +461,8 @@ export abstract class BaseEdgeQuery<
       new LastFilter({
         limit: n,
         before,
-        sortCol: this.sortCol,
         cursorCol: this.cursorCol,
         cursorKeys: this.cursorKeys,
-        cursorColIsDate: this.cursorColIsDate,
-        sortColIsDate: this.sortColIsDate,
         orderby: this.edgeQueryOptions.orderby,
         query: this,
         fieldOptions: this.edgeQueryOptions.fieldOptions,
@@ -875,14 +699,10 @@ export abstract class BaseEdgeQuery<
     return this.edges;
   }
 
-  protected includeSortColInCursor(options: EdgeQueryOptions) {
-    return false;
-  }
-
   getCursor(row: TEdge): string {
     return getCursor({
       row,
-      keys: this.cursorKeys,
+      cursorKeys: this.cursorKeys,
     });
   }
 }
diff --git a/ts/src/core/query_impl.ts b/ts/src/core/query_impl.ts
index ec28057db..6db6a7351 100644
--- a/ts/src/core/query_impl.ts
+++ b/ts/src/core/query_impl.ts
@@ -5,11 +5,6 @@ export interface OrderByOption {
   direction: "ASC" | "DESC";
   alias?: string;
   nullsPlacement?: "first" | "last";
-  // is this column a date/time column?
-  // needed to know if we create a cursor based on this column to conver to timestamp and ISO string for
-  // comparison
-  // maybe eventually want a more generic version of this but for now this suffices
-  dateColumn?: boolean;
 }
 
 export type OrderBy = OrderByOption[];
diff --git a/ts/src/graphql/query/shared_edge_connection.ts b/ts/src/graphql/query/shared_edge_connection.ts
index cb572ed28..c0378d39f 100644
--- a/ts/src/graphql/query/shared_edge_connection.ts
+++ b/ts/src/graphql/query/shared_edge_connection.ts
@@ -121,15 +121,16 @@ export const commonTests = <TEdge extends Data>(
     if (isCustomQuery(q)) {
       opts = {
         row: contacts[idx],
-        keys: ["id"],
+        cursorKeys: ["created_at", "id"],
+        rowKeys: ["createdAt", "id"],
       };
     } else {
       // for assoc queries, we're getting the value from 'id' field but the edge
       // is from assoc_edge table id2 field and so cursor takes it from there
       opts = {
         row: contacts[idx],
-        keys: ["id2"],
-        cursorKeys: ["id"],
+        cursorKeys: ["time", "id2"],
+        rowKeys: ["createdAt", "id"],
       };
     }
     return getCursor(opts);


Test patch:
diff --git a/ts/src/core/clause.test.ts b/ts/src/core/clause.test.ts
index 9e80230f4..24fe59311 100644
--- a/ts/src/core/clause.test.ts
+++ b/ts/src/core/clause.test.ts
@@ -1928,6 +1928,577 @@ describe("postgres", () => {
     });
   });
 
+  describe("pagination unbound cols query", () => {
+    test("All DESC", () => {
+      const cls = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "DESC",
+          sortValue: "timeValue",
+        },
+        {
+          sortCol: "id",
+          direction: "DESC",
+          sortValue: "idValue",
+        },
+      ])!;
+      expect(cls.clause(1)).toBe(
+        "(start_time < $1 AND start_time IS NOT NULL) OR (start_time = $2 AND id < $3 AND id IS NOT NULL)",
+      );
+      expect(cls.clause(1, "t")).toBe(
+        "(t.start_time < $1 AND t.start_time IS NOT NULL) OR (t.start_time = $2 AND t.id < $3 AND t.id IS NOT NULL)",
+      );
+      expect(cls.columns()).toStrictEqual([
+        "start_time",
+        "start_time",
+        "start_time",
+        "id",
+        "id",
+      ]);
+      expect(cls.values()).toStrictEqual(["timeValue", "timeValue", "idValue"]);
+      expect(cls.logValues()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls.instanceKey()).toEqual(
+        "(start_time<timeValue AND start_time IS NOT NULL) OR (start_time=timeValue AND id<idValue AND id IS NOT NULL)",
+      );
+
+      const cls2 = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "DESC",
+          sortValue: "timeValue",
+          overrideAlias: "t2",
+        },
+        {
+          sortCol: "id",
+          direction: "DESC",
+          sortValue: "idValue",
+          overrideAlias: "t2",
+        },
+      ])!;
+      expect(cls2.clause(1)).toBe(
+        "(t2.start_time < $1 AND t2.start_time IS NOT NULL) OR (t2.start_time = $2 AND t2.id < $3 AND t2.id IS NOT NULL)",
+      );
+      expect(cls2.clause(1, "t")).toBe(
+        "(t2.start_time < $1 AND t2.start_time IS NOT NULL) OR (t2.start_time = $2 AND t2.id < $3 AND t2.id IS NOT NULL)",
+      );
+      expect(cls2.columns()).toStrictEqual([
+        "start_time",
+        "start_time",
+        "start_time",
+        "id",
+        "id",
+      ]);
+      expect(cls2.values()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls2.logValues()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls2.instanceKey()).toEqual(
+        "(t2.start_time<timeValue AND start_timet2 IS NOT NULL) OR (t2.start_time=timeValue AND t2.id<idValue AND idt2 IS NOT NULL)",
+      );
+    });
+
+    test("All DESC clause 3", () => {
+      const cls = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "DESC",
+          sortValue: "timeValue",
+        },
+        {
+          sortCol: "id",
+          direction: "DESC",
+          sortValue: "idValue",
+        },
+      ])!;
+      expect(cls.clause(3)).toBe(
+        "(start_time < $3 AND start_time IS NOT NULL) OR (start_time = $4 AND id < $5 AND id IS NOT NULL)",
+      );
+      expect(cls.clause(3, "t")).toBe(
+        "(t.start_time < $3 AND t.start_time IS NOT NULL) OR (t.start_time = $4 AND t.id < $5 AND t.id IS NOT NULL)",
+      );
+      expect(cls.columns()).toStrictEqual([
+        "start_time",
+        "start_time",
+        "start_time",
+        "id",
+        "id",
+      ]);
+      expect(cls.values()).toStrictEqual(["timeValue", "timeValue", "idValue"]);
+      expect(cls.logValues()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls.instanceKey()).toEqual(
+        "(start_time<timeValue AND start_time IS NOT NULL) OR (start_time=timeValue AND id<idValue AND id IS NOT NULL)",
+      );
+
+      const cls2 = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "DESC",
+          sortValue: "timeValue",
+          overrideAlias: "t2",
+        },
+        {
+          sortCol: "id",
+          direction: "DESC",
+          sortValue: "idValue",
+          overrideAlias: "t2",
+        },
+      ])!;
+      expect(cls2.clause(3)).toBe(
+        "(t2.start_time < $3 AND t2.start_time IS NOT NULL) OR (t2.start_time = $4 AND t2.id < $5 AND t2.id IS NOT NULL)",
+      );
+      expect(cls2.clause(3, "t")).toBe(
+        "(t2.start_time < $3 AND t2.start_time IS NOT NULL) OR (t2.start_time = $4 AND t2.id < $5 AND t2.id IS NOT NULL)",
+      );
+      expect(cls2.columns()).toStrictEqual([
+        "start_time",
+        "start_time",
+        "start_time",
+        "id",
+        "id",
+      ]);
+      expect(cls2.values()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls2.logValues()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls2.instanceKey()).toEqual(
+        "(t2.start_time<timeValue AND start_timet2 IS NOT NULL) OR (t2.start_time=timeValue AND t2.id<idValue AND idt2 IS NOT NULL)",
+      );
+    });
+
+    test("All ASC", () => {
+      const cls = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "ASC",
+          sortValue: "timeValue",
+        },
+        {
+          sortCol: "id",
+          direction: "ASC",
+          sortValue: "idValue",
+        },
+      ])!;
+      expect(cls.clause(1)).toBe(
+        "start_time > $1 OR start_time IS NULL OR (start_time = $2 AND (id > $3 OR id IS NULL))",
+      );
+      expect(cls.clause(1, "t")).toBe(
+        "t.start_time > $1 OR t.start_time IS NULL OR (t.start_time = $2 AND (t.id > $3 OR t.id IS NULL))",
+      );
+      expect(cls.columns()).toStrictEqual([
+        "start_time",
+        "start_time",
+        "start_time",
+        "id",
+        "id",
+      ]);
+      expect(cls.values()).toStrictEqual(["timeValue", "timeValue", "idValue"]);
+      expect(cls.logValues()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls.instanceKey()).toEqual(
+        "start_time>timeValue OR start_time IS NULL OR (start_time=timeValue AND (id>idValue OR id IS NULL))",
+      );
+
+      const cls2 = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "ASC",
+          sortValue: "timeValue",
+          overrideAlias: "t2",
+        },
+        {
+          sortCol: "id",
+          direction: "ASC",
+          sortValue: "idValue",
+          overrideAlias: "t2",
+        },
+      ])!;
+      expect(cls2.clause(1)).toBe(
+        "t2.start_time > $1 OR t2.start_time IS NULL OR (t2.start_time = $2 AND (t2.id > $3 OR t2.id IS NULL))",
+      );
+      expect(cls2.clause(1, "t")).toBe(
+        "t2.start_time > $1 OR t2.start_time IS NULL OR (t2.start_time = $2 AND (t2.id > $3 OR t2.id IS NULL))",
+      );
+      expect(cls2.columns()).toStrictEqual([
+        "start_time",
+        "start_time",
+        "start_time",
+        "id",
+        "id",
+      ]);
+      expect(cls2.values()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls2.logValues()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls2.instanceKey()).toEqual(
+        "t2.start_time>timeValue OR start_timet2 IS NULL OR (t2.start_time=timeValue AND (t2.id>idValue OR idt2 IS NULL))",
+      );
+    });
+
+    test("DESC NULLS LAST", () => {
+      const cls = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "DESC",
+          nullsPlacement: "last",
+          sortValue: "timeValue",
+        },
+        {
+          sortCol: "id",
+          direction: "DESC",
+          nullsPlacement: "last",
+          sortValue: "idValue",
+        },
+      ])!;
+      expect(cls.clause(1)).toBe(
+        "start_time < $1 OR start_time IS NULL OR (start_time = $2 AND (id < $3 OR id IS NULL))",
+      );
+      expect(cls.clause(1, "t")).toBe(
+        "t.start_time < $1 OR t.start_time IS NULL OR (t.start_time = $2 AND (t.id < $3 OR t.id IS NULL))",
+      );
+      expect(cls.columns()).toStrictEqual([
+        "start_time",
+        "start_time",
+        "start_time",
+        "id",
+        "id",
+      ]);
+      expect(cls.values()).toStrictEqual(["timeValue", "timeValue", "idValue"]);
+      expect(cls.logValues()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls.instanceKey()).toEqual(
+        "start_time<timeValue OR start_time IS NULL OR (start_time=timeValue AND (id<idValue OR id IS NULL))",
+      );
+
+      const cls2 = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "DESC",
+          nullsPlacement: "last",
+          sortValue: "timeValue",
+          overrideAlias: "t2",
+        },
+        {
+          sortCol: "id",
+          direction: "DESC",
+          nullsPlacement: "last",
+          sortValue: "idValue",
+          overrideAlias: "t2",
+        },
+      ])!;
+      expect(cls2.clause(1)).toBe(
+        "t2.start_time < $1 OR t2.start_time IS NULL OR (t2.start_time = $2 AND (t2.id < $3 OR t2.id IS NULL))",
+      );
+      expect(cls2.clause(1, "t")).toBe(
+        "t2.start_time < $1 OR t2.start_time IS NULL OR (t2.start_time = $2 AND (t2.id < $3 OR t2.id IS NULL))",
+      );
+      expect(cls2.columns()).toStrictEqual([
+        "start_time",
+        "start_time",
+        "start_time",
+        "id",
+        "id",
+      ]);
+      expect(cls2.values()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls2.logValues()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls2.instanceKey()).toEqual(
+        "t2.start_time<timeValue OR start_timet2 IS NULL OR (t2.start_time=timeValue AND (t2.id<idValue OR idt2 IS NULL))",
+      );
+    });
+
+    test("ASC NULLS FIRST", () => {
+      const cls = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "ASC",
+          nullsPlacement: "first",
+          sortValue: "timeValue",
+        },
+        {
+          sortCol: "id",
+          direction: "ASC",
+          nullsPlacement: "first",
+          sortValue: "idValue",
+        },
+      ])!;
+      expect(cls.clause(1)).toBe(
+        "(start_time > $1 AND start_time IS NOT NULL) OR (start_time = $2 AND id > $3 AND id IS NOT NULL)",
+      );
+      expect(cls.clause(1, "t")).toBe(
+        "(t.start_time > $1 AND t.start_time IS NOT NULL) OR (t.start_time = $2 AND t.id > $3 AND t.id IS NOT NULL)",
+      );
+      expect(cls.columns()).toStrictEqual([
+        "start_time",
+        "start_time",
+        "start_time",
+        "id",
+        "id",
+      ]);
+      expect(cls.values()).toStrictEqual(["timeValue", "timeValue", "idValue"]);
+      expect(cls.logValues()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls.instanceKey()).toEqual(
+        "(start_time>timeValue AND start_time IS NOT NULL) OR (start_time=timeValue AND id>idValue AND id IS NOT NULL)",
+      );
+
+      const cls2 = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "ASC",
+          nullsPlacement: "first",
+          sortValue: "timeValue",
+          overrideAlias: "t2",
+        },
+        {
+          sortCol: "id",
+          direction: "ASC",
+          nullsPlacement: "first",
+          sortValue: "idValue",
+          overrideAlias: "t2",
+        },
+      ])!;
+      expect(cls2.clause(1)).toBe(
+        "(t2.start_time > $1 AND t2.start_time IS NOT NULL) OR (t2.start_time = $2 AND t2.id > $3 AND t2.id IS NOT NULL)",
+      );
+      expect(cls2.clause(1, "t")).toBe(
+        "(t2.start_time > $1 AND t2.start_time IS NOT NULL) OR (t2.start_time = $2 AND t2.id > $3 AND t2.id IS NOT NULL)",
+      );
+      expect(cls2.columns()).toStrictEqual([
+        "start_time",
+        "start_time",
+        "start_time",
+        "id",
+        "id",
+      ]);
+      expect(cls2.values()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls2.logValues()).toStrictEqual([
+        "timeValue",
+        "timeValue",
+        "idValue",
+      ]);
+      expect(cls2.instanceKey()).toEqual(
+        "(t2.start_time>timeValue AND start_timet2 IS NOT NULL) OR (t2.start_time=timeValue AND t2.id>idValue AND idt2 IS NOT NULL)",
+      );
+    });
+
+    test("DESC defaults to NULLS FIRST", () => {
+      const cls = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "DESC",
+          sortValue: "timeValue",
+        },
+        {
+          sortCol: "id",
+          direction: "DESC",
+          sortValue: "idValue",
+        },
+      ])!;
+      const cls2 = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "DESC",
+          nullsPlacement: "first",
+          sortValue: "timeValue",
+        },
+        {
+          sortCol: "id",
+          direction: "DESC",
+          nullsPlacement: "first",
+          sortValue: "idValue",
+        },
+      ])!;
+      expect(cls.clause(1)).toEqual(cls2.clause(1));
+    });
+
+    test("ASC defaults to NULLS LAST", () => {
+      const cls = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "ASC",
+          sortValue: "timeValue",
+        },
+        {
+          sortCol: "id",
+          direction: "ASC",
+          sortValue: "idValue",
+        },
+      ])!;
+      const cls2 = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "ASC",
+          nullsPlacement: "last",
+          sortValue: "timeValue",
+        },
+        {
+          sortCol: "id",
+          direction: "ASC",
+          nullsPlacement: "last",
+          sortValue: "idValue",
+        },
+      ])!;
+      expect(cls.clause(1)).toEqual(cls2.clause(1));
+    });
+
+    test("DESC with NULL", () => {
+      const cls = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "DESC",
+          sortValue: "timeValue",
+        },
+        {
+          sortCol: "id",
+          direction: "DESC",
+          sortValue: null,
+        },
+      ])!;
+      expect(cls.clause(1)).toBe("start_time < $1 AND start_time IS NOT NULL");
+      const cls2 = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "DESC",
+          sortValue: null,
+        },
+        {
+          sortCol: "id",
+          direction: "DESC",
+          sortValue: "idValue",
+        },
+      ])!;
+      expect(cls2.clause(1)).toBe("id < $1 AND id IS NOT NULL");
+      const cls3 = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "DESC",
+          sortValue: null,
+        },
+        {
+          sortCol: "id",
+          direction: "DESC",
+          sortValue: null,
+        },
+      ]);
+      expect(cls3).toBeUndefined();
+    });
+
+    test("ASC with NULL", () => {
+      const cls = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "ASC",
+          sortValue: "timeValue",
+        },
+        {
+          sortCol: "id",
+          direction: "ASC",
+          sortValue: null,
+        },
+      ])!;
+      expect(cls.clause(1)).toBe(
+        "start_time > $1 OR start_time IS NULL OR (start_time = $2 AND id IS NULL)",
+      );
+      const cls2 = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "ASC",
+          sortValue: null,
+        },
+        {
+          sortCol: "id",
+          direction: "ASC",
+          sortValue: "idValue",
+        },
+      ])!;
+      expect(cls2.clause(1)).toBe(
+        "start_time IS NULL AND (id > $1 OR id IS NULL)",
+      );
+      const cls3 = clause.PaginationUnboundColsQuery<EventData>([
+        {
+          sortCol: "start_time",
+          direction: "ASC",
+          sortValue: null,
+        },
+        {
+          sortCol: "id",
+          direction: "ASC",
+          sortValue: null,
+        },
+      ])!;
+      expect(cls3.clause(1)).toBe("start_time IS NULL AND id IS NULL");
+    });
+
+    test("3 level multi-directional", () => {
+      const cls = clause.PaginationUnboundColsQuery<any>([
+        {
+          sortCol: "first_name",
+          direction: "ASC",
+          sortValue: "Stefan",
+        },
+        {
+          sortCol: "last_name",
+          direction: "DESC",
+          sortValue: "Parker",
+        },
+        {
+          sortCol: "id",
+          direction: "ASC",
+          sortValue: "24900783",
+        },
+      ])!;
+      expect(cls.clause(1)).toBe(
+        "first_name > $1 OR first_name IS NULL OR (first_name = $2 AND ((last_name < $3 AND last_name IS NOT NULL) OR (last_name = $4 AND (id > $5 OR id IS NULL))))",
+      );
+    });
+  });
+
   describe("rhs", () => {
     test("add", () => {
       const cls = clause.Add<BalanceData>("balance", 4);
diff --git a/ts/src/core/query/assoc_query.test.ts b/ts/src/core/query/assoc_query.test.ts
index 449e6c919..23bde9ad7 100644
--- a/ts/src/core/query/assoc_query.test.ts
+++ b/ts/src/core/query/assoc_query.test.ts
@@ -1,9 +1,9 @@
-import { Viewer } from "../base";
 import { FakeUser, UserToContactsQuery } from "../../testutils/fake_data/index";
-import { commonTests } from "./shared_test";
-import { assocTests } from "./shared_assoc_test";
 import { MockLogs } from "../../testutils/mock_log";
+import { Viewer } from "../base";
 import { And, Eq } from "../clause";
+import { assocTests } from "./shared_assoc_test";
+import { commonTests } from "./shared_test";
 
 // shared mock across tests
 // should this be global?
@@ -24,6 +24,10 @@ commonTests({
       column: "time",
       direction: "DESC",
     },
+    {
+      column: "id2",
+      direction: "DESC",
+    },
   ],
   livePostgresDB: true,
 });
diff --git a/ts/src/core/query/assoc_query_global.test.ts b/ts/src/core/query/assoc_query_global.test.ts
index 22ec09381..68511d8bf 100644
--- a/ts/src/core/query/assoc_query_global.test.ts
+++ b/ts/src/core/query/assoc_query_global.test.ts
@@ -1,17 +1,17 @@
-import { Viewer } from "../base";
 import {
   EdgeType,
   FakeUser,
   UserToContactsQuery,
 } from "../../testutils/fake_data/index";
 import { inputs } from "../../testutils/fake_data/test_helpers";
-import { commonTests } from "./shared_test";
-import { assocTests } from "./shared_assoc_test";
-import { loadCustomEdges } from "../ent";
-import { EdgeWithDeletedAt } from "../../testutils/test_edge_global_schema";
-import { convertDate } from "../convert";
 import { MockLogs } from "../../testutils/mock_log";
+import { EdgeWithDeletedAt } from "../../testutils/test_edge_global_schema";
+import { Viewer } from "../base";
 import { And, Eq } from "../clause";
+import { convertDate } from "../convert";
+import { loadCustomEdges } from "../ent";
+import { assocTests } from "./shared_assoc_test";
+import { commonTests } from "./shared_test";
 
 const ml = new MockLogs();
 ml.mock();
@@ -55,6 +55,10 @@ commonTests({
       column: "time",
       direction: "DESC",
     },
+    {
+      column: "id2",
+      direction: "DESC",
+    },
   ],
 });
 
diff --git a/ts/src/core/query/assoc_query_sqlite.test.ts b/ts/src/core/query/assoc_query_sqlite.test.ts
index e1f2dc9c5..d611d8bcf 100644
--- a/ts/src/core/query/assoc_query_sqlite.test.ts
+++ b/ts/src/core/query/assoc_query_sqlite.test.ts
@@ -1,17 +1,17 @@
-import { Viewer } from "../base";
+import { setupSqlite } from "../../testutils/db/temp_db";
 import {
   EdgeType,
   FakeUser,
   UserToContactsQuery,
 } from "../../testutils/fake_data/index";
-import { commonTests } from "./shared_test";
-import { assocTests } from "./shared_assoc_test";
-import { loadCustomEdges } from "../ent";
-import { EdgeWithDeletedAt } from "../../testutils/test_edge_global_schema";
+import { tempDBTables } from "../../testutils/fake_data/test_helpers";
 import { MockLogs } from "../../testutils/mock_log";
+import { EdgeWithDeletedAt } from "../../testutils/test_edge_global_schema";
+import { Viewer } from "../base";
 import { And, Eq } from "../clause";
-import { setupSqlite } from "../../testutils/db/temp_db";
-import { tempDBTables } from "../../testutils/fake_data/test_helpers";
+import { loadCustomEdges } from "../ent";
+import { assocTests } from "./shared_assoc_test";
+import { commonTests } from "./shared_test";
 
 const ml = new MockLogs();
 ml.mock();
@@ -51,6 +51,10 @@ describe("assoc query desc", () => {
         column: "time",
         direction: "DESC",
       },
+      {
+        column: "id2",
+        direction: "DESC",
+      },
     ],
   });
 });
diff --git a/ts/src/core/query/assoc_query_sqlite_global.test.ts b/ts/src/core/query/assoc_query_sqlite_global.test.ts
index d2c4f3a44..71d192052 100644
--- a/ts/src/core/query/assoc_query_sqlite_global.test.ts
+++ b/ts/src/core/query/assoc_query_sqlite_global.test.ts
@@ -1,18 +1,18 @@
-import { Viewer } from "../base";
+import { convertDate } from "../../core/convert";
+import { setupSqlite } from "../../testutils/db/temp_db";
 import {
   EdgeType,
   FakeUser,
   UserToContactsQuery,
 } from "../../testutils/fake_data/index";
-import { commonTests } from "./shared_test";
-import { assocTests } from "./shared_assoc_test";
-import { loadCustomEdges } from "../ent";
-import { EdgeWithDeletedAt } from "../../testutils/test_edge_global_schema";
 import { inputs, tempDBTables } from "../../testutils/fake_data/test_helpers";
-import { convertDate } from "../../core/convert";
 import { MockLogs } from "../../testutils/mock_log";
+import { EdgeWithDeletedAt } from "../../testutils/test_edge_global_schema";
+import { Viewer } from "../base";
 import { And, Eq } from "../clause";
-import { setupSqlite } from "../../testutils/db/temp_db";
+import { loadCustomEdges } from "../ent";
+import { assocTests } from "./shared_assoc_test";
+import { commonTests } from "./shared_test";
 
 const ml = new MockLogs();
 ml.mock();
@@ -57,6 +57,10 @@ commonTests({
       column: "time",
       direction: "DESC",
     },
+    {
+      column: "id2",
+      direction: "DESC",
+    },
   ],
 });
 
diff --git a/ts/src/core/query/complex_custom_query.test.ts b/ts/src/core/query/complex_custom_query.test.ts
index 336b12fd7..7d76ee3bf 100644
--- a/ts/src/core/query/complex_custom_query.test.ts
+++ b/ts/src/core/query/complex_custom_query.test.ts
@@ -1,15 +1,17 @@
-import { v1 } from "uuid";
 import { DateTime } from "luxon";
-import { MockLogs } from "../../testutils/mock_log";
+import { v1 } from "uuid";
+import { AnyEnt, SimpleBuilder } from "../../testutils/builder";
+import { TempDB, integer, table, text, uuid } from "../../testutils/db/temp_db";
+import { randomEmail } from "../../testutils/db/value";
 import {
+  EdgeType,
   FakeEvent,
+  FakeEventSchema,
   FakeUser,
-  UserToEventsInNextWeekQuery,
-  EdgeType,
   FakeUserSchema,
-  getNextWeekClause,
-  FakeEventSchema,
+  UserToEventsInNextWeekQuery,
   ViewerWithAccessToken,
+  getNextWeekClause,
 } from "../../testutils/fake_data";
 import {
   addEdge,
@@ -18,20 +20,16 @@ import {
   inputs,
   setupTempDB,
 } from "../../testutils/fake_data/test_helpers";
-import { setLogLevels } from "../logger";
-import { TempDB, integer, table, text, uuid } from "../../testutils/db/temp_db";
-import { buildQuery, reverseOrderBy } from "../query_impl";
-import { loadCustomEnts } from "../ent";
-import * as clause from "../clause";
-import { Data, Ent, Viewer, WriteOperation } from "../base";
-import { CustomClauseQuery } from "./custom_clause_query";
-import { AnyEnt, SimpleBuilder } from "../../testutils/builder";
-import { OrderByOption } from "../query_impl";
+import { MockLogs } from "../../testutils/mock_log";
 import { createRowForTest } from "../../testutils/write";
-import { randomEmail } from "../../testutils/db/value";
+import { Data, Ent, Viewer, WriteOperation } from "../base";
+import * as clause from "../clause";
 import DB from "../db";
+import { loadCustomEnts } from "../ent";
+import { setLogLevels } from "../logger";
+import { OrderByOption, buildQuery, reverseOrderBy } from "../query_impl";
 import { LoggedOutViewer } from "../viewer";
-import { load } from "js-yaml";
+import { CustomClauseQuery } from "./custom_clause_query";
 
 const INTERVAL = 24 * 60 * 60 * 1000;
 
@@ -123,7 +121,11 @@ const getGlobalQuery = (viewer?: Viewer, opts?: Partial<OrderByOption>) => {
       {
         column: "start_time",
         direction: "DESC",
-        dateColumn: true,
+        ...opts,
+      },
+      {
+        column: "id",
+        direction: "DESC",
         ...opts,
       },
     ],
@@ -153,7 +155,11 @@ const getCreatorsOfGlobalEventsInNextWeek = (opts?: Partial<OrderByOption>) => {
       {
         column: "created_at",
         direction: "DESC",
-        dateColumn: true,
+        ...opts,
+      },
+      {
+        column: "id",
+        direction: "DESC",
         ...opts,
       },
     ],
@@ -181,6 +187,10 @@ const getEndTimeGlobalQuery = (
         direction: "DESC",
         ...opts,
       },
+      {
+        column: "id",
+        direction: opts?.direction ?? "DESC",
+      },
     ],
   });
 };
@@ -272,18 +282,23 @@ describe("query for user", () => {
         },
       ],
       limit: 3,
-      clause: clause.And(
+      clause: clause.AndOptional(
         clause.Eq("user_id", user.id),
         clause.GreaterEq("start_time", 1),
         clause.LessEq("start_time", 2),
         // the cursor check
-        clause.PaginationMultipleColsSubQuery(
-          "start_time",
-          "<",
-          FakeEvent.loaderOptions().tableName,
-          "id",
-          4,
-        ),
+        clause.PaginationUnboundColsQuery([
+          {
+            sortCol: "start_time",
+            direction: "DESC",
+            sortValue: 1,
+          },
+          {
+            sortCol: "id",
+            direction: "DESC",
+            sortValue: 4,
+          },
+        ]),
       ),
     });
     expect(query).toEqual(ml.logs[ml.logs.length - 1].query);
@@ -441,13 +456,18 @@ describe("global query", () => {
           clause.LessEq("start_time", 2),
           // the cursor check
           cursor
-            ? clause.PaginationMultipleColsSubQuery(
-                "start_time",
-                "<",
-                FakeEvent.loaderOptions().tableName,
-                "id",
-                4,
-              )
+            ? clause.PaginationUnboundColsQuery([
+                {
+                  sortCol: "start_time",
+                  direction: "DESC",
+                  sortValue: 1,
+                },
+                {
+                  sortCol: "id",
+                  direction: "DESC",
+                  sortValue: 4,
+                },
+              ])
             : undefined,
         ),
       });
@@ -537,13 +557,18 @@ describe("global query", () => {
           clause.LessEq("start_time", 2),
           // the cursor check
           cursor
-            ? clause.PaginationMultipleColsSubQuery(
-                "start_time",
-                ">",
-                FakeEvent.loaderOptions().tableName,
-                "id",
-                4,
-              )
+            ? clause.PaginationUnboundColsQuery([
+                {
+                  sortCol: "start_time",
+                  direction: "ASC",
+                  sortValue: 1,
+                },
+                {
+                  sortCol: "id",
+                  direction: "ASC",
+                  sortValue: 4,
+                },
+              ])
             : undefined,
         ),
       });
@@ -1151,13 +1176,13 @@ describe("joins - products", () => {
           SELECT
       p.id,
           COUNT(DISTINCT o.user_id) AS num_users
-      FROM 
+      FROM
           orders o
-      JOIN 
+      JOIN
           products p ON p.id = o.product_id
-      GROUP BY 
+      GROUP BY
           p.id
-      ORDER BY 
+      ORDER BY
           num_users DESC
       LIMIT 1;
 `);
@@ -1184,7 +1209,6 @@ describe("joins - products", () => {
         `,
         [productId],
       );
-
     expect(r2.rows.length).toEqual(expCount);
 
     // now let's do an ent query for it and the results should be the same
@@ -1199,6 +1223,10 @@ describe("joins - products", () => {
             column: "name",
             direction: "DESC",
           },
+          {
+            column: "id",
+            direction: "DESC",
+          },
         ],
         joinBETA: [
           {
@@ -1293,13 +1321,13 @@ describe("joins - products", () => {
           SELECT
       u.id,
           COUNT(DISTINCT o.product_id) AS num_products
-      FROM 
+      FROM
           users u
-      JOIN 
+      JOIN
           orders o ON u.id = o.user_id
-      GROUP BY 
+      GROUP BY
           u.id
-      ORDER BY 
+      ORDER BY
           num_products DESC
       LIMIT 1;
 `);
@@ -1372,6 +1400,10 @@ describe("joins - products", () => {
             column: "product_name",
             direction: "DESC",
           },
+          {
+            column: "id",
+            direction: "DESC",
+          },
         ],
         joinBETA: [
           {
@@ -1426,6 +1458,10 @@ describe("joins - products", () => {
             column: "product_name",
             direction: "DESC",
           },
+          {
+            column: "id",
+            direction: "DESC",
+          },
         ],
         joinBETA: [
           {
@@ -1464,29 +1500,29 @@ describe("joins - products", () => {
   test("query products for user in given category", async () => {
     const r = await DB.getInstance().getPool().query(`
 WITH UserCategoryOrders AS (
-    SELECT 
+    SELECT
         u.id as uid,
         c.id as cid,
         COUNT(DISTINCT o.product_id) AS num_products
-    FROM 
+    FROM
         users u
-    JOIN 
+    JOIN
         orders o ON u.id = o.user_id
-    JOIN 
+    JOIN
         products p ON o.product_id = p.id
-    JOIN 
+    JOIN
         categories c ON p.category_id = c.id
-    GROUP BY 
+    GROUP BY
         u.id, c.id
 )
 
-SELECT 
+SELECT
     uid,
     cid,
     num_products
-FROM 
+FROM
     UserCategoryOrders
-ORDER BY 
+ORDER BY
     num_products DESC
 LIMIT 1;
 `);
@@ -1537,6 +1573,10 @@ LIMIT 1;
             column: "product_name",
             direction: "DESC",
           },
+          {
+            column: "id",
+            direction: "DESC",
+          },
         ],
         joinBETA: [
           {
@@ -1592,25 +1632,26 @@ function getPaginationVerifyClauseWithJoin<T extends Ent>(
     const options = q2.__getOptions();
 
     const orderBy = options.orderby!;
-    expect(orderBy.length).toBe(1);
-    const less = orderBy[0].direction === "DESC";
-    orderBy.push({
-      column: "id",
-      direction: orderBy[0].direction,
+    orderBy.forEach((o) => {
+      o.alias =
+        o.alias ??
+        (options.loadEntOptions.disableFieldsAlias
+          ? undefined
+          : options.loadEntOptions.fieldsAlias);
     });
+    expect(orderBy.length).toBe(2);
     let cls = options.clause;
     if (cursor) {
       cls = clause.AndOptional(
         cls,
-        clause.PaginationMultipleColsQuery(
-          // same column from orderBy
-          orderBy[0].column,
-          "id",
-          less,
-          // these 2 values don't matter so just putting whatever
-          new Date().getTime(),
-          4,
-          options.loadEntOptions.fieldsAlias ?? options.loadEntOptions.alias,
+        clause.PaginationUnboundColsQuery(
+          orderBy.map((o) => ({
+            sortCol: o.column,
+            direction: o.direction,
+            // This value doesn't matter so just putting whatever
+            sortValue: "_",
+            overrideAlias: o.alias,
+          })),
         ),
       );
     }
diff --git a/ts/src/core/query/custom_query_live.test.ts b/ts/src/core/query/custom_query_live.test.ts
index b17ebc469..7390342e2 100644
--- a/ts/src/core/query/custom_query_live.test.ts
+++ b/ts/src/core/query/custom_query_live.test.ts
@@ -1,12 +1,12 @@
-import { Viewer } from "../base";
 import {
   FakeUser,
   UserToContactsFkeyQuery,
   UserToContactsFkeyQueryAsc,
 } from "../../testutils/fake_data/index";
-import { commonTests } from "./shared_test";
 import { MockLogs } from "../../testutils/mock_log";
+import { Viewer } from "../base";
 import { Eq } from "../clause";
+import { commonTests } from "./shared_test";
 
 const ml = new MockLogs();
 ml.mock();
@@ -25,6 +25,10 @@ describe("custom query", () => {
         column: "created_at",
         direction: "DESC",
       },
+      {
+        column: "id",
+        direction: "DESC",
+      },
     ],
     livePostgresDB: true,
   });
@@ -44,6 +48,10 @@ describe("custom query ASC", () => {
         column: "created_at",
         direction: "ASC",
       },
+      {
+        column: "id",
+        direction: "ASC",
+      },
     ],
     livePostgresDB: true,
   });
diff --git a/ts/src/core/query/custom_query_sqlite.test.ts b/ts/src/core/query/custom_query_sqlite.test.ts
index 2be17579e..716f98a09 100644
--- a/ts/src/core/query/custom_query_sqlite.test.ts
+++ b/ts/src/core/query/custom_query_sqlite.test.ts
@@ -1,12 +1,12 @@
-import { Viewer } from "../base";
 import {
   FakeUser,
   UserToContactsFkeyQuery,
   UserToContactsFkeyQueryAsc,
 } from "../../testutils/fake_data/index";
-import { commonTests } from "./shared_test";
 import { MockLogs } from "../../testutils/mock_log";
+import { Viewer } from "../base";
 import { Eq } from "../clause";
+import { commonTests } from "./shared_test";
 
 const ml = new MockLogs();
 ml.mock();
@@ -23,7 +23,10 @@ describe("custom query", () => {
     orderby: [
       {
         column: "created_at",
-        // dateColumn: true,
+        direction: "DESC",
+      },
+      {
+        column: "id",
         direction: "DESC",
       },
     ],
@@ -43,7 +46,10 @@ describe("custom query asc", () => {
     orderby: [
       {
         column: "created_at",
-        dateColumn: true,
+        direction: "ASC",
+      },
+      {
+        column: "id",
         direction: "ASC",
       },
     ],
diff --git a/ts/src/core/query/custom_query_sqlite_global.test.ts b/ts/src/core/query/custom_query_sqlite_global.test.ts
index dffb48cac..58f275662 100644
--- a/ts/src/core/query/custom_query_sqlite_global.test.ts
+++ b/ts/src/core/query/custom_query_sqlite_global.test.ts
@@ -1,16 +1,14 @@
-import { Context, Viewer } from "../base";
 import {
   FakeContact,
   FakeContactSchemaWithDeletedAt,
   FakeUser,
-  UserToContactsFkeyQuery,
-  UserToContactsFkeyQueryAsc,
   UserToContactsFkeyQueryDeletedAt,
   UserToContactsFkeyQueryDeletedAtAsc,
 } from "../../testutils/fake_data/index";
-import { commonTests } from "./shared_test";
 import { MockLogs } from "../../testutils/mock_log";
+import { Context, Viewer } from "../base";
 import * as clause from "../clause";
+import { commonTests } from "./shared_test";
 
 const ml = new MockLogs();
 ml.mock();
@@ -30,6 +28,10 @@ describe("custom query", () => {
         column: "created_at",
         direction: "DESC",
       },
+      {
+        column: "id",
+        direction: "DESC",
+      },
     ],
     globalSchema: true,
     loadEnt: (v: Viewer, id: string) => FakeContact.loadXWithDeletedAt(v, id),
@@ -54,6 +56,10 @@ describe("custom query ASC", () => {
         column: "created_at",
         direction: "ASC",
       },
+      {
+        column: "id",
+        direction: "ASC",
+      },
     ],
     globalSchema: true,
     loadEnt: (v: Viewer, id: string) => FakeContact.loadXWithDeletedAt(v, id),
diff --git a/ts/src/core/query/shared_test.ts b/ts/src/core/query/shared_test.ts
index da782a3dd..f928f5591 100644
--- a/ts/src/core/query/shared_test.ts
+++ b/ts/src/core/query/shared_test.ts
@@ -1,50 +1,49 @@
-import { Data, ID, Viewer } from "../base";
-import {
-  getDefaultLimit,
-  AssocEdge,
-  getCursor,
-  setDefaultLimit,
-  cursorOptions,
-} from "../ent";
-import { setGlobalSchema } from "../global_schema";
-import { IDViewer, LoggedOutViewer } from "../viewer";
+import { WriteOperation } from "../../action";
+import { BuilderSchema, SimpleAction } from "../../testutils/builder";
+import { TestContext } from "../../testutils/context/test_context";
+import { setupSqlite, TempDB } from "../../testutils/db/temp_db";
 import {
-  FakeUser,
-  FakeContact,
-  UserToContactsFkeyQueryDeprecated,
-  FakeUserSchema,
   EdgeType,
+  FakeContact,
   FakeContactSchema,
+  FakeUser,
+  FakeUserSchema,
   UserToContactsFkeyQuery,
   UserToContactsFkeyQueryAsc,
-  FakeContactSchemaWithDeletedAt,
   UserToContactsFkeyQueryDeletedAt,
   UserToContactsFkeyQueryDeletedAtAsc,
+  UserToContactsFkeyQueryDeprecated,
 } from "../../testutils/fake_data/index";
 import {
-  inputs,
   createAllContacts,
-  verifyUserToContactRawData,
-  verifyUserToContactEdges,
-  verifyUserToContacts,
   createEdges,
+  inputs,
   setupTempDB,
   tempDBTables,
+  verifyUserToContactEdges,
+  verifyUserToContactRawData,
+  verifyUserToContacts,
 } from "../../testutils/fake_data/test_helpers";
-import { EdgeQuery } from "./query";
-import { setupSqlite, TempDB } from "../../testutils/db/temp_db";
-import { TestContext } from "../../testutils/context/test_context";
-import { setLogLevels } from "../logger";
-import { testEdgeGlobalSchema } from "../../testutils/test_edge_global_schema";
-import { BuilderSchema, SimpleAction } from "../../testutils/builder";
-import { WriteOperation } from "../../action";
 import { MockLogs } from "../../testutils/mock_log";
-import { Clause, PaginationMultipleColsSubQuery } from "../clause";
-import { OrderBy, getOrderByPhrase, reverseOrderBy } from "../query_impl";
 import {
   getVerifyAfterEachCursorGeneric,
   getWhereClause,
 } from "../../testutils/query";
+import { testEdgeGlobalSchema } from "../../testutils/test_edge_global_schema";
+import { Data, ID, Viewer } from "../base";
+import { Clause, PaginationUnboundColsQuery } from "../clause";
+import {
+  AssocEdge,
+  cursorOptions,
+  getCursor,
+  getDefaultLimit,
+  setDefaultLimit,
+} from "../ent";
+import { setGlobalSchema } from "../global_schema";
+import { setLogLevels } from "../logger";
+import { getOrderByPhrase, OrderBy, reverseOrderBy } from "../query_impl";
+import { IDViewer, LoggedOutViewer } from "../viewer";
+import { EdgeQuery } from "./query";
 
 interface options<TData extends Data> {
   newQuery: (
@@ -275,7 +274,7 @@ export const commonTests = <TData extends Data>(opts: options<TData>) => {
         // default limit
         `${opts.clause.clause(1)} ORDER BY ${getOrderByPhrase(
           orderby,
-        )}, ${uniqCol} ${orderby[0].direction} LIMIT ${expLimit}`,
+        )} LIMIT ${expLimit}`,
       );
     }
   }
@@ -296,30 +295,28 @@ export const commonTests = <TData extends Data>(opts: options<TData>) => {
     // cache showing up in a few because of cross runs...
     expect(ml.logs.length).toBeGreaterThanOrEqual(length);
 
-    const uniqCol = isCustomQuery(filter) ? "id" : "id2";
     let parts = opts.clause.clause(1).split(" AND ");
-    const cmp = PaginationMultipleColsSubQuery(
-      opts.orderby[0].column,
-      opts.orderby[0].direction === "DESC" ? "<" : ">",
-      opts.tableName,
-      uniqCol,
-      "",
-    ).clause(opts.clause.values().length + 1);
+    const cmp = PaginationUnboundColsQuery(
+      opts.orderby.map((orderBy) => ({
+        direction: orderBy.direction,
+        sortCol: orderBy.column,
+        sortValue: "_",
+      })),
+    )!.clause(opts.clause.values().length + 1);
 
     // order of parts is different in custom query seemingly
     const customQuery = isCustomQuery(filter);
     if (!customQuery && parts[parts.length - 1] === "deleted_at IS NULL") {
       parts = parts
         .slice(0, parts.length - 1)
-        .concat([cmp, "deleted_at IS NULL"]);
+        .concat([`(${cmp})`, "deleted_at IS NULL"]);
     } else {
-      parts.push(cmp);
+      parts.push(`(${cmp})`);
     }
-
     expect(getWhereClause(ml.logs[0])).toBe(
       `${parts.join(" AND ")} ORDER BY ${getOrderByPhrase(
         opts.orderby,
-      )}, ${uniqCol} ${opts.orderby[0].direction} LIMIT ${limit + 1}`,
+      )} LIMIT ${limit + 1}`,
     );
   }
 
@@ -330,32 +327,31 @@ export const commonTests = <TData extends Data>(opts: options<TData>) => {
     // cache showing up in a few because of cross runs...
     expect(ml.logs.length).toBeGreaterThanOrEqual(length);
 
-    const uniqCol = isCustomQuery(filter) ? "id" : "id2";
-
     let parts = opts.clause.clause(1).split(" AND ");
-    const cmp = PaginationMultipleColsSubQuery(
-      orderby[0].column,
-      orderby[0].direction === "ASC" ? ">" : "<",
-      opts.tableName,
-      uniqCol,
-      "",
-    ).clause(opts.clause.values().length + 1);
+    const cmp = PaginationUnboundColsQuery(
+      opts.orderby.map((orderBy) => ({
+        // Reverse order for "last" cursor
+        direction: orderBy.direction === "DESC" ? "ASC" : "DESC",
+        sortCol: orderBy.column,
+        sortValue: "_",
+      })),
+    )!.clause(opts.clause.values().length + 1);
 
     // order of parts is different in custom query seemingly
     const customQuery = isCustomQuery(filter);
     if (!customQuery && parts[parts.length - 1] === "deleted_at IS NULL") {
       parts = parts
         .slice(0, parts.length - 1)
-        .concat([cmp, "deleted_at IS NULL"]);
+        .concat([`(${cmp})`, "deleted_at IS NULL"]);
     } else {
-      parts.push(cmp);
+      parts.push(`(${cmp})`);
     }
 
     expect(getWhereClause(ml.logs[0])).toBe(
       // extra fetched for pagination
       `${parts.join(" AND ")} ORDER BY ${getOrderByPhrase(
         orderby,
-      )}, ${uniqCol} ${orderby[0].direction} LIMIT ${limit + 1}`,
+      )} LIMIT ${limit + 1}`,
     );
   }
 
@@ -372,15 +368,16 @@ export const commonTests = <TData extends Data>(opts: options<TData>) => {
     if (isCustomQuery(q)) {
       opts = {
         row: contacts[idx],
-        keys: ["id"],
+        cursorKeys: ["created_at", "id"],
+        rowKeys: ["createdAt", "id"],
       };
     } else {
       // for assoc queries, we're getting the value from 'id' field but the edge
       // is from assoc_edge table id2 field and so cursor takes it from there
       opts = {
         row: contacts[idx],
-        keys: ["id2"],
-        cursorKeys: ["id"],
+        cursorKeys: ["time", "id2"],
+        rowKeys: ["createdAt", "id"],
       };
     }
     return getCursor(opts);
diff --git a/ts/src/core/query/unique_col_query.test.ts b/ts/src/core/query/unique_col_query.test.ts
index c39045ba4..bde9bdac4 100644
--- a/ts/src/core/query/unique_col_query.test.ts
+++ b/ts/src/core/query/unique_col_query.test.ts
@@ -1,10 +1,4 @@
-import { MockLogs } from "../../testutils/mock_log";
 import { TempDB } from "../../testutils/db/temp_db";
-import { setLogLevels } from "../logger";
-import {
-  createTestUser,
-  setupTempDB,
-} from "../../testutils/fake_data/test_helpers";
 import {
   createTag,
   FakeTag,
@@ -12,10 +6,16 @@ import {
   UserToTagsFkeyQuery,
   UserToTagsFkeyQueryAsc,
 } from "../../testutils/fake_data";
+import {
+  createTestUser,
+  setupTempDB,
+} from "../../testutils/fake_data/test_helpers";
+import { MockLogs } from "../../testutils/mock_log";
 import { Viewer } from "../base";
+import { And, AndOptional, Eq, Greater, Less, NotEq, Or } from "../clause";
 import { getDefaultLimit } from "../ent";
+import { setLogLevels } from "../logger";
 import { buildQuery } from "../query_impl";
-import { AndOptional, Eq, Greater, Less } from "../clause";
 import { EdgeQuery } from "./query";
 
 let tdb: TempDB;
@@ -87,10 +87,10 @@ function tests(
     const limit = opts?.limit || getDefaultLimit();
     const canonicalName = opts?.canonicalName;
     let orderby = dir;
-    let fn = dir === "DESC" ? Less : Greater;
+    let lessThan = dir === "DESC";
     if (opts?.last) {
       orderby = dir === "ASC" ? "DESC" : "ASC";
-      fn = dir === "DESC" ? Greater : Less;
+      lessThan = !lessThan;
     }
     expect(ml.logs.length).toBe(2);
     expect(ml.logs[1]).toStrictEqual({
@@ -99,7 +99,17 @@ function tests(
         fields: FakeTag.loaderOptions().fields,
         clause: AndOptional(
           Eq("owner_id", user.id),
-          canonicalName ? fn("canonical_name", canonicalName) : undefined,
+          canonicalName
+            ? lessThan
+              ? And(
+                  Less("canonical_name", canonicalName),
+                  NotEq("canonical_name", null),
+                )
+              : Or(
+                  Greater("canonical_name", canonicalName),
+                  Eq("canonical_name", null),
+                )
+            : undefined,
         ),
         orderby: [
           {
diff --git a/ts/src/testutils/db_mock.ts b/ts/src/testutils/db_mock.ts
index 77f6f6e5a..58c32bf5a 100644
--- a/ts/src/testutils/db_mock.ts
+++ b/ts/src/testutils/db_mock.ts
@@ -1,11 +1,11 @@
-import { v4 as uuidv4 } from "uuid";
-import { Pool, PoolClient } from "pg";
 import { mocked } from "jest-mock";
-import { ID, Data } from "../core/base";
+import { Pool, PoolClient } from "pg";
+import { v4 as uuidv4 } from "uuid";
+import { Data, ID } from "../core/base";
 import { Clause } from "../core/clause";
 
-import { performQuery, queryResult, getDataToReturn } from "./parse_sql";
 import { MockLogs } from "./mock_log";
+import { getDataToReturn, performQuery, queryResult } from "./parse_sql";
 
 const eventEmitter = {
   on: jest.fn(),
diff --git a/ts/src/testutils/ent-graphql-tests/index.ts b/ts/src/testutils/ent-graphql-tests/index.ts
index 895c7e0b9..aa88a0f6f 100644
--- a/ts/src/testutils/ent-graphql-tests/index.ts
+++ b/ts/src/testutils/ent-graphql-tests/index.ts
@@ -1,30 +1,31 @@
 // NB: this is copied from ent-graphql-tests package until I have time to figure out how to share code here effectively
 // the circular dependencies btw this package and ent-graphql-tests seems to imply something needs to change
 import express, { Express, RequestHandler } from "express";
+import * as fs from "fs";
 import {
-  getGraphQLParameters,
-  processRequest,
-  ExecutionContext,
-  sendResult,
-} from "graphql-helix";
-import { Viewer } from "../../core/base";
-import {
-  GraphQLSchema,
-  GraphQLObjectType,
-  GraphQLScalarType,
-  isWrappingType,
   GraphQLArgument,
+  GraphQLField,
+  GraphQLFieldMap,
   GraphQLList,
-  isScalarType,
+  GraphQLObjectType,
+  GraphQLScalarType,
+  GraphQLSchema,
   GraphQLType,
-  GraphQLFieldMap,
-  GraphQLField,
   isEnumType,
+  isScalarType,
+  isWrappingType,
 } from "graphql";
-import { buildContext, registerAuthHandler } from "../../auth";
+import {
+  ExecutionContext,
+  getGraphQLParameters,
+  processRequest,
+  sendResult,
+} from "graphql-helix";
+import { IncomingHttpHeaders } from "http";
 import supertest from "supertest";
-import * as fs from "fs";
 import { inspect } from "util";
+import { buildContext, registerAuthHandler } from "../../auth";
+import { Viewer } from "../../core/base";
 
 function server(config: queryConfig): Express {
   const viewer = config.viewer;
@@ -127,7 +128,7 @@ function makeGraphQLRequest(
   if (files.size) {
     let ret = test
       .post(config.graphQLPath || "/graphql")
-      .set(config.headers || {});
+      .set((config.headers || {}) as IncomingHttpHeaders);
 
     ret.field(
       "operations",
@@ -161,7 +162,7 @@ function makeGraphQLRequest(
       test,
       test
         .post(config.graphQLPath || "/graphql")
-        .set(config.headers || {})
+        .set((config.headers || {}) as IncomingHttpHeaders)
         .send({
           query: query,
           variables: JSON.stringify(variables),
diff --git a/ts/src/testutils/fake_data/user_query.ts b/ts/src/testutils/fake_data/user_query.ts
index 5ef305e55..5da59e918 100644
--- a/ts/src/testutils/fake_data/user_query.ts
+++ b/ts/src/testutils/fake_data/user_query.ts
@@ -1,32 +1,31 @@
+import { clear } from "jest-date-mock";
+import { getLoaderOptions } from ".";
 import { Data, Ent, ID, Viewer } from "../../core/base";
-import { CustomEdgeQueryBase } from "../../core/query/custom_query";
-import { AssocEdge } from "../../core/ent";
 import * as clause from "../../core/clause";
+import { AssocEdge } from "../../core/ent";
+import { AssocEdgeCountLoaderFactory } from "../../core/loaders/assoc_count_loader";
+import { AssocEdgeLoaderFactory } from "../../core/loaders/assoc_edge_loader";
+import { QueryLoaderFactory } from "../../core/loaders/query_loader";
+import { RawCountLoaderFactory } from "../../core/loaders/raw_count_loader";
+import { AllowIfViewerPrivacyPolicy } from "../../core/privacy";
 import {
   AssocEdgeQueryBase,
   EdgeQuerySource,
 } from "../../core/query/assoc_query";
+import { CustomEdgeQueryBase } from "../../core/query/custom_query";
+import { MockDate } from "./../mock_date";
+import { contactLoader } from "./fake_contact";
 import {
   EdgeType,
-  FakeUser,
-  FakeEvent,
-  FakeContact,
   EventToAttendeesQuery,
   EventToDeclinedQuery,
   EventToHostsQuery,
   EventToInvitedQuery,
   EventToMaybeQuery,
+  FakeContact,
+  FakeEvent,
+  FakeUser,
 } from "./internal";
-import { RawCountLoaderFactory } from "../../core/loaders/raw_count_loader";
-import { AssocEdgeCountLoaderFactory } from "../../core/loaders/assoc_count_loader";
-import { AssocEdgeLoaderFactory } from "../../core/loaders/assoc_edge_loader";
-import { contactLoader } from "./fake_contact";
-import { clear } from "jest-date-mock";
-import { Interval } from "luxon";
-import { QueryLoaderFactory } from "../../core/loaders/query_loader";
-import { MockDate } from "./../mock_date";
-import { getLoaderOptions } from ".";
-import { AllowIfViewerPrivacyPolicy } from "../../core/privacy";
 
 export class UserToContactsQuery extends AssocEdgeQueryBase<
   FakeUser,
@@ -503,13 +502,11 @@ export const getNextWeekClause = (): clause.Clause => {
   clear();
   const start = MockDate.getDate();
   // 7 days
-  const end = Interval.after(start, 86400 * 1000 * 7)
-    .end.toUTC()
-    .toISO();
+  const end = new Date(start.getTime() + 86400 * 1000 * 7);
 
   return clause.And(
     clause.GreaterEq("start_time", start.toISOString()),
-    clause.LessEq("start_time", end),
+    clause.LessEq("start_time", end.toISOString()),
   );
 };