@@ -844,14 +844,31 @@ export class PostgresRunStore implements RunStore {
844844 ) : Promise < unknown > {
845845 const prisma = client ?? this . readOnlyPrisma ;
846846
847+ // Offset pagination can't be expressed across two tables: applying `skip`
848+ // to each table independently skips N rows from each, not N from the merged
849+ // result. Reject it rather than silently double-skip. No caller uses it;
850+ // cross-table reads keyset-paginate on a where + (createdAt, id) orderBy.
851+ if ( args . skip !== undefined ) {
852+ throw new Error (
853+ "RunStore.findRuns: `skip` (offset pagination) is not supported across the legacy TaskRun " +
854+ "and task_run_v2 tables. Use a where-based keyset (createdAt + id) instead."
855+ ) ;
856+ }
857+
847858 // A run lives in exactly one physical table, chosen by its id format, so a
848- // multi-row read must hit BOTH `TaskRun` (legacy cuid) and `task_run_v2`
849- // (new ksuid) and combine. `task_run_v2` is an identical clone of `TaskRun`
850- // (same relation surface), so the SAME `args` — crucially the SAME `where`,
851- // which is the security scope — run unchanged against either delegate.
859+ // multi-row read generally hits BOTH `TaskRun` (legacy cuid) and
860+ // `task_run_v2` (new ksuid) and combines. `task_run_v2` is an identical
861+ // clone of `TaskRun` (same relation surface), so the SAME `args` (crucially
862+ // the SAME `where`, which is the security scope) run unchanged against
863+ // either delegate. When the predicate is an `id: { in: [...] }` list, the
864+ // table with no candidate ids is skipped (a cuid can't live in task_run_v2,
865+ // nor a ksuid in TaskRun), avoiding an empty query while task_run_v2 is
866+ // unpopulated during rollout.
852867 const legacyModel = prisma . taskRun ;
853868 const v2Model = prisma . taskRunV2 as unknown as typeof prisma . taskRun ;
854869
870+ const { queryLegacy, queryV2 } = this . #tablesForWhere( args . where ) ;
871+
855872 const ordered = this . #normalizeOrderBy( args . orderBy ) ;
856873
857874 // ORDERED + LIMITED → bounded 2-way merge.
@@ -881,8 +898,8 @@ export class PostgresRunStore implements RunStore {
881898 const perTableArgs = { ...queryArgs , take : args . take } ;
882899
883900 const [ legacyRows , v2Rows ] = ( await Promise . all ( [
884- legacyModel . findMany ( perTableArgs ) ,
885- v2Model . findMany ( perTableArgs ) ,
901+ queryLegacy ? legacyModel . findMany ( perTableArgs ) : Promise . resolve ( [ ] ) ,
902+ queryV2 ? v2Model . findMany ( perTableArgs ) : Promise . resolve ( [ ] ) ,
886903 ] ) ) as [ Array < Record < string , unknown > > , Array < Record < string , unknown > > ] ;
887904
888905 const merged = this . #mergeOrdered( legacyRows , v2Rows , comparator , args . take ) ;
@@ -901,8 +918,8 @@ export class PostgresRunStore implements RunStore {
901918 : { args, addedKeys : [ ] as string [ ] } ;
902919
903920 const [ legacyRows , v2Rows ] = ( await Promise . all ( [
904- legacyModel . findMany ( queryArgs ) ,
905- v2Model . findMany ( queryArgs ) ,
921+ queryLegacy ? legacyModel . findMany ( queryArgs ) : Promise . resolve ( [ ] ) ,
922+ queryV2 ? v2Model . findMany ( queryArgs ) : Promise . resolve ( [ ] ) ,
906923 ] ) ) as [ Array < Record < string , unknown > > , Array < Record < string , unknown > > ] ;
907924
908925 let combined = legacyRows . concat ( v2Rows ) ;
@@ -924,6 +941,38 @@ export class PostgresRunStore implements RunStore {
924941 return this . #stripAddedKeys( combined , addedKeys ) ;
925942 }
926943
944+ /**
945+ * Which physical tables a `findRuns` predicate can match. A run id encodes
946+ * its table, so an `id: { in: [...] }` list containing only cuids cannot match
947+ * `task_run_v2` (and a ksuid-only list cannot match `TaskRun`): the table with
948+ * no candidate ids is skipped, avoiding a wasted query against an empty
949+ * `task_run_v2` during rollout. An empty `in` list matches nothing, so both
950+ * are skipped. Any other predicate must consult both tables.
951+ */
952+ #tablesForWhere( where : Prisma . TaskRunWhereInput ) : { queryLegacy : boolean ; queryV2 : boolean } {
953+ const idFilter = where . id ;
954+ const idIn =
955+ idFilter !== null && typeof idFilter === "object" && "in" in idFilter
956+ ? ( idFilter as { in ?: unknown } ) . in
957+ : undefined ;
958+
959+ if ( Array . isArray ( idIn ) ) {
960+ let queryLegacy = false ;
961+ let queryV2 = false ;
962+ for ( const id of idIn ) {
963+ if ( typeof id === "string" && isKsuidId ( id ) ) {
964+ queryV2 = true ;
965+ } else {
966+ queryLegacy = true ;
967+ }
968+ if ( queryLegacy && queryV2 ) break ;
969+ }
970+ return { queryLegacy, queryV2 } ;
971+ }
972+
973+ return { queryLegacy : true , queryV2 : true } ;
974+ }
975+
927976 /**
928977 * The cross-table merge/sort compares order-key VALUES read off each returned
929978 * row, so every scalar order key must be present in the projection. When the
0 commit comments